summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 16:09:56 +0900
committerAdam Malczewski <[email protected]>2026-06-22 16:09:56 +0900
commit2a7708bd492a5a78794c76ee43355cabe786943e (patch)
treeb285acdda11231fb3b25e6fceb32af268dfa8a29
parent1df0831551a06496294ea40f9001bf3a86f1bc09 (diff)
downloaddispatch-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.svelte1
-rw-r--r--src/app/store.svelte.ts13
-rw-r--r--src/features/tabs/ui/TabBar.svelte59
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}