diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 16:09:56 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 16:09:56 +0900 |
| commit | 2a7708bd492a5a78794c76ee43355cabe786943e (patch) | |
| tree | b285acdda11231fb3b25e6fceb32af268dfa8a29 | |
| parent | 1df0831551a06496294ea40f9001bf3a86f1bc09 (diff) | |
| download | dispatch-web-2a7708bd492a5a78794c76ee43355cabe786943e.tar.gz dispatch-web-2a7708bd492a5a78794c76ee43355cabe786943e.zip | |
feat: double-click tab to rename (PUT /conversations/:id/title)
Double-click a tab's title to enter inline edit mode. Enter or click
away (blur) saves; Escape cancels. The rename is optimistic — the
local tab updates immediately and PUT /title fires in the background.
683 tests green.
| -rw-r--r-- | src/app/App.svelte | 1 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 13 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabBar.svelte | 59 |
3 files changed, 72 insertions, 1 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index ae09bd5..9225cc7 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -309,6 +309,7 @@ onSelect={(id) => store.selectTab(id)} onClose={(id) => store.closeTab(id)} onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} /> <span class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30" diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 3f78a97..6cff5f8 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -18,6 +18,7 @@ import type { SetCompactPercentRequest, SetCwdRequest, SetReasoningEffortRequest, + SetTitleRequest, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; @@ -114,6 +115,7 @@ export interface AppStore { newDraft(): void; selectTab(conversationId: string): void; closeTab(conversationId: string): void; + renameTab(conversationId: string, title: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; /** * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). @@ -869,6 +871,17 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { removeTabLocally(conversationId); }, + renameTab(conversationId: string, title: string): void { + tabsStore.setTitle(conversationId, title); + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title } satisfies SetTitleRequest), + }).catch(() => { + // Best-effort — the local tab is already renamed. + }); + }, + invoke(surfaceId: string, actionId: string, payload?: unknown): void { const result = protocolInvoke( protocol, diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte index 9d224b9..f783412 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabBar.svelte @@ -9,6 +9,7 @@ onSelect, onClose, onNewDraft, + onRename, }: { tabs: readonly Tab[]; activeConversationId: string | null; @@ -17,6 +18,7 @@ onSelect: (conversationId: string) => void; onClose: (conversationId: string) => void; onNewDraft: () => void; + onRename?: (conversationId: string, title: string) => void; } = $props(); // The new-chat button is `position: sticky; right: 0`. It floats over the tabs @@ -65,6 +67,31 @@ ro?.disconnect(); }; }); + // Inline rename: double-click a tab's title to edit, Enter/blur to save. + let editingId = $state<string | null>(null); + let editValue = $state(""); + let editEl = $state<HTMLInputElement>(); + + function startRename(tab: Tab): void { + if (onRename === undefined) return; + editingId = tab.conversationId; + editValue = tab.title; + // Focus the input after it renders. + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + const id = editingId; + if (id !== null && onRename !== undefined) { + const trimmed = editValue.trim(); + if (trimmed.length > 0) onRename(id, trimmed); + } + editingId = null; + } + + function cancelRename(): void { + editingId = null; + } </script> <div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto"> @@ -86,7 +113,37 @@ > {handles.get(tab.conversationId) ?? tab.conversationId} </span> - <span class="min-w-0 flex-1 truncate text-left">{tab.title}</span> + {#if editingId === tab.conversationId} + <input + bind:this={editEl} + bind:value={editValue} + class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelRename(); + } + }} + onblur={commitRename} + /> + {:else} + <span + class="min-w-0 flex-1 cursor-pointer truncate text-left" + role="button" + tabindex="-1" + title="Double-click to rename" + ondblclick={(e) => { + e.stopPropagation(); + startRename(tab); + }} + > + {tab.title} + </span> + {/if} {#if statusFor?.(tab.conversationId) === "active"} <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span> {/if} |
