diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 14:43:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 14:43:16 +0900 |
| commit | 3ebcd49c404ed287a97af159ac8adfa63d572849 (patch) | |
| tree | f8245ed28530a8e96046221eb1d7eca47d508dc8 /packages/frontend/src/lib | |
| parent | 7c527b4d8a72159954405e720d5bf776802dc0ff (diff) | |
| download | dispatch-3ebcd49c404ed287a97af159ac8adfa63d572849.tar.gz dispatch-3ebcd49c404ed287a97af159ac8adfa63d572849.zip | |
feat(tabs): drag-reorder + double-click rename + per-tab chat draft
- TabBar: HTML5 drag-and-drop to reorder user tabs (subagent tabs untouched);
double-click a tab title to rename (Enter/blur confirm, Escape cancel).
- Store: add reorderTabs/renameTab/setDraft; per-tab in-memory `draft` and
`manualTitle` fields. Manual rename suppresses first-message auto-title.
- ChatInput: bind to the active tab's draft so switching tabs saves/restores
unsent text instead of clobbering it.
- Backend: updateTabPositions() + PATCH /tabs/reorder persist tab order to the
existing `position` column; tabs without a stored position fall to the end
then get explicit positions on first reorder.
- Tests: store reorder/rename/auto-title-guard/draft coverage; core
updateTabPositions coverage (FakeDatabase extended with transaction support).
Diffstat (limited to 'packages/frontend/src/lib')
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 21 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TabBar.svelte | 89 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 81 |
3 files changed, 182 insertions, 9 deletions
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 0c99078..71eb496 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -4,12 +4,17 @@ import { tabStore } from "../tabs.svelte.js"; const MAX_LINES = 7; let inputEl: HTMLTextAreaElement | undefined; -let inputValue = $state(""); const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle"); const tabId = $derived(tabStore.activeTab?.id ?? ""); +// The current input text lives on the active tab (in-memory draft), so +// switching tabs saves the current draft and restores the target tab's text +// automatically — drafts are never lost or clobbered by tab switching. +const inputValue = $derived(tabStore.activeTab?.draft ?? ""); $effect(() => { + // Re-focus when switching tabs. + void tabId; inputEl?.focus(); }); @@ -29,13 +34,19 @@ function resize() { el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; } -// Re-run resize whenever the value changes (covers programmatic clears too). +// Re-run resize whenever the value changes (covers tab switches and +// programmatic clears too). $effect(() => { // Touch inputValue so this effect tracks it. void inputValue; resize(); }); +function handleInput(e: Event) { + if (!tabId) return; + tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value); +} + function handleKeydown(e: KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -46,7 +57,7 @@ function handleKeydown(e: KeyboardEvent) { function submit() { const text = inputValue.trim(); if (!text) return; - inputValue = ""; + if (tabId) tabStore.setDraft(tabId, ""); tabStore.sendMessage(text); } </script> @@ -75,12 +86,12 @@ function submit() { {/if} <textarea bind:this={inputEl} - bind:value={inputValue} + value={inputValue} rows="1" placeholder="Type a message..." class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" onkeydown={handleKeydown} - oninput={resize} + oninput={handleInput} ></textarea> <button type="button" diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index 3cbd849..4fbe3b1 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -1,4 +1,5 @@ <script lang="ts"> +import { tick } from "svelte"; import { tabStore } from "../tabs.svelte.js"; function statusColor(status: string): string { @@ -20,6 +21,59 @@ const activeUserTabId = $derived( ? activeTab.parentTabId : tabStore.activeTabId, ); + +// ── Drag-and-drop reorder (user tabs only) ── +// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte. +let dragIndex = $state<number | null>(null); +let dragOverIndex = $state<number | null>(null); + +function dropReorder(targetIndex: number): void { + if (dragIndex !== null && dragIndex !== targetIndex) { + const ids = userTabs.map((t) => t.id); + const moved = ids.splice(dragIndex, 1)[0]; + if (moved) { + ids.splice(targetIndex, 0, moved); + tabStore.reorderTabs(ids); + } + } + dragIndex = null; + dragOverIndex = null; +} + +// ── Double-click rename (user tabs only) ── +let editingTabId = $state<string | null>(null); +let editValue = $state(""); +let editInputEl = $state<HTMLInputElement | undefined>(undefined); + +async function startRename(tab: { id: string; title: string }): Promise<void> { + editingTabId = tab.id; + editValue = tab.title; + await tick(); + editInputEl?.focus(); + editInputEl?.select(); +} + +function commitRename(): void { + if (editingTabId === null) return; + const id = editingTabId; + editingTabId = null; + const next = editValue.trim(); + if (next) tabStore.renameTab(id, next); +} + +function cancelRename(): void { + editingTabId = null; +} + +function handleRenameKeydown(e: KeyboardEvent): void { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelRename(); + } +} </script> <!-- Top row: user tabs --> @@ -45,19 +99,48 @@ const activeUserTabId = $derived( + </button> - {#each userTabs as tab (tab.id)} + {#each userTabs as tab, i (tab.id)} <!-- svelte-ignore a11y_no_static_element_interactions --> <div role="tab" - class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''}" + class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}" + draggable={editingTabId === tab.id ? "false" : "true"} onclick={() => tabStore.switchTab(tab.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }} + ondragstart={(e) => { + dragIndex = i; + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + }} + ondragover={(e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragOverIndex = i; + }} + ondragleave={() => { if (dragOverIndex === i) dragOverIndex = null; }} + ondrop={(e) => { e.preventDefault(); dropReorder(i); }} + ondragend={() => { dragIndex = null; dragOverIndex = null; }} tabindex="0" > <span class="flex items-center gap-1.5"> <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span> <span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span> - <span class="max-w-32 truncate text-xs">{tab.title}</span> + {#if editingTabId === tab.id} + <input + bind:this={editInputEl} + bind:value={editValue} + class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40" + onclick={(e) => e.stopPropagation()} + ondblclick={(e) => e.stopPropagation()} + onkeydown={handleRenameKeydown} + onblur={commitRename} + /> + {:else} + <span + class="max-w-32 truncate text-xs" + ondblclick={(e) => { e.stopPropagation(); startRename(tab); }} + title="Double-click to rename" + >{tab.title}</span> + {/if} </span> <button type="button" diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index ec718bd..1c94593 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -177,6 +177,19 @@ export interface Tab { /** Total chunk count for this tab on the backend (drives "more to load?"). */ totalChunks: number; /** + * Unsent chat-input text for THIS tab (in-memory only — never persisted). + * Saved/restored on tab switch so a draft is never lost or clobbered by + * switching tabs. Cleared on send. + */ + draft: string; + /** + * True once the user has manually renamed this tab (double-click rename). + * Suppresses the first-message auto-title so a chosen name is never + * clobbered. In-memory only — a renamed tab is no longer "New Tab" on + * reload, so the auto-title guard already won't fire for it. + */ + manualTitle: boolean; + /** * Cumulative prompt-cache token telemetry for this tab since the page * loaded (in-memory only — resets on reload). Undefined until the first * `usage` event arrives. Drives the "Cache Rate" sidebar view. @@ -298,6 +311,8 @@ export function createTabStore() { workingDirectory: null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: null, totalChunks: 0, }; @@ -373,6 +388,8 @@ export function createTabStore() { workingDirectory: null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: win.oldestSeq, totalChunks: win.total, }; @@ -426,6 +443,61 @@ export function createTabStore() { } /** + * Rename a tab. Records `manualTitle` so the first-message auto-title never + * clobbers the user's chosen name, and persists the new title to the DB + * (fire-and-forget — the optimistic local update is the source of truth for + * the open session). + */ + function renameTab(id: string, title: string): void { + const trimmed = title.trim(); + if (!trimmed) return; + updateTab(id, { title: trimmed, manualTitle: true }); + fetch(`${config.apiBase}/tabs/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: trimmed }), + }).catch(() => {}); + } + + /** + * Reorder the top-row USER tabs to match `orderedUserTabIds`. Subagent tabs + * (those with a `parentTabId`) keep their relative order untouched — they + * live in a separate row and aren't draggable. The new left-to-right user + * order is persisted via `PATCH /tabs/reorder`, which rewrites each open + * tab's `position` (fire-and-forget, matching the title-persist style). + */ + function reorderTabs(orderedUserTabIds: string[]): void { + const byId = new Map(tabs.map((t) => [t.id, t])); + const ordered = orderedUserTabIds + .map((id) => byId.get(id)) + .filter((t): t is Tab => t !== undefined && t.parentTabId === null); + // Bail if the requested order doesn't cover exactly the current user tabs + // (stale drag against a since-changed tab set) — never drop tabs. + const currentUserCount = tabs.filter((t) => t.parentTabId === null).length; + if (ordered.length !== currentUserCount) return; + const subagentTabs = tabs.filter((t) => t.parentTabId !== null); + tabs = [...ordered, ...subagentTabs]; + // Persist the full open-tab order (user tabs first, then subagents) so the + // backend `position` column matches what the user sees on reload. + const persistOrder = [...ordered, ...subagentTabs].map((t) => t.id); + fetch(`${config.apiBase}/tabs/reorder`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: persistOrder }), + }).catch(() => {}); + } + + /** + * Persist the unsent chat-input text for a tab (in-memory only). Saved on + * every keystroke so switching tabs preserves the draft and restoring the + * target tab shows its own text. No-op if the tab is gone. + */ + function setDraft(id: string, text: string): void { + if (!getTabById(id)) return; + updateTab(id, { draft: text }); + } + + /** * Record whether a tab's chat view is scrolled up (viewing older history). * Used to suppress automatic eviction while the user is reading old * messages — we don't want to delete what they're currently looking at. @@ -854,6 +926,8 @@ export function createTabStore() { workingDirectory: null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: win.oldestSeq, totalChunks: win.total, cacheStats: row.usageStats ?? undefined, @@ -1203,6 +1277,8 @@ export function createTabStore() { workingDirectory: newTabEvent.workingDirectory ?? null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: null, totalChunks: 0, }; @@ -1589,7 +1665,7 @@ export function createTabStore() { updateTab(tab.id, { live: [...tab.live, userMsg] }); // Generate a title from the first user message of an empty tab. const isFirstMessage = tab.chunks.length === 0 && tab.live.length === 0; - if (isFirstMessage || tab.title === "New Tab") { + if (!tab.manualTitle && (isFirstMessage || tab.title === "New Tab")) { const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text; updateTab(tab.id, { title: titleText }); fetch(`${config.apiBase}/tabs/${tab.id}`, { @@ -2033,6 +2109,9 @@ export function createTabStore() { createNewTab, switchTab, closeTab, + renameTab, + reorderTabs, + setDraft, sendMessage, cancelQueuedMessage, stopGeneration, |
