From 3ebcd49c404ed287a97af159ac8adfa63d572849 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Tue, 2 Jun 2026 14:43:16 +0900 Subject: 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). --- packages/api/src/routes/tabs.ts | 13 ++ packages/core/src/db/tabs.ts | 14 ++ packages/core/src/index.ts | 1 + packages/core/tests/db/tabs.test.ts | 69 ++++++++- .../frontend/src/lib/components/ChatInput.svelte | 21 ++- packages/frontend/src/lib/components/TabBar.svelte | 89 +++++++++++- packages/frontend/src/lib/tabs.svelte.ts | 81 ++++++++++- packages/frontend/tests/chat-store.test.ts | 154 +++++++++++++++++++++ 8 files changed, 431 insertions(+), 11 deletions(-) diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts index f52ee99..28a89f1 100644 --- a/packages/api/src/routes/tabs.ts +++ b/packages/api/src/routes/tabs.ts @@ -11,6 +11,7 @@ import { listOpenTabs, setSetting, updateTabModel, + updateTabPositions, updateTabStatus, updateTabTitle, } from "@dispatch/core"; @@ -63,6 +64,18 @@ tabsRoutes.put("/settings/title-model", async (c) => { return c.json({ success: true }); }); +// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids; +// each tab's `position` is rewritten to its index. Must be declared before the +// `/:id` routes so "reorder" isn't captured as an id param. +tabsRoutes.patch("/reorder", async (c) => { + const body = await c.req.json<{ ids?: string[] }>(); + if (!Array.isArray(body.ids) || body.ids.some((id) => typeof id !== "string")) { + return c.json({ error: "ids must be an array of strings" }, 400); + } + updateTabPositions(body.ids); + return c.json({ success: true }); +}); + tabsRoutes.get("/:id", (c) => { const id = c.req.param("id"); const tab = getTab(id); diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts index 8b290d2..f719a01 100644 --- a/packages/core/src/db/tabs.ts +++ b/packages/core/src/db/tabs.ts @@ -115,6 +115,20 @@ export function updateTabStatus(id: string, status: string): void { }); } +export function updateTabPositions(idsInOrder: string[]): void { + const db = getDatabase(); + const now = Date.now(); + const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id"); + // One transaction so a reorder is atomic: either every tab lands at its new + // slot or none does, never a half-applied ordering. + const applyAll = db.transaction(() => { + idsInOrder.forEach((id, index) => { + update.run({ $id: id, $position: index, $now: now }); + }); + }); + applyAll(); +} + export function archiveTab(id: string): void { const db = getDatabase(); db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7818024..f67ad53 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,7 @@ export { shortestUniquePrefix, type TabRow, updateTabModel, + updateTabPositions, updateTabStatus, updateTabTitle, } from "./db/tabs.js"; diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts index 67533dc..2cd226b 100644 --- a/packages/core/tests/db/tabs.test.ts +++ b/packages/core/tests/db/tabs.test.ts @@ -50,6 +50,15 @@ class FakeDatabase { }; } + /** + * Match Bun's `db.transaction(fn)` shape: returns a callable that runs + * `fn` synchronously. The fake is in-memory and single-threaded, so we + * don't emulate rollback — callers just need the wrapper to be invocable. + */ + transaction(fn: () => void): () => void { + return () => fn(); + } + private execSelect(sql: string, params?: Record): unknown[] { const norm = sql.replace(/\s+/g, " ").trim(); @@ -89,6 +98,11 @@ class FakeDatabase { return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id })); } + // listOpenTabs: every open tab ordered by position. + if (norm === "SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") { + return this.rows.filter((r) => r.is_open === 1).sort((a, b) => a.position - b.position); + } + throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`); } @@ -129,6 +143,16 @@ class FakeDatabase { return; } + // updateTabPositions: rewrite a single tab's position (run per id inside a txn) + if (norm === "UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id") { + const row = this.rows.find((r) => r.id === params?.$id); + if (row) { + row.position = (params?.$position as number) ?? row.position; + row.updated_at = (params?.$now as number) ?? Date.now(); + } + return; + } + throw new Error(`FakeDatabase: unsupported mutation: ${norm}`); } } @@ -150,8 +174,16 @@ vi.mock("../../src/db/index.js", () => ({ // Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to // the very top of the file, so by the time this line runs the mock is // active for `./index.js` resolution inside `tabs.ts`). -const { archiveTab, createTab, getDescendantIds, getTab, resolveTabPrefix, shortestUniquePrefix } = - await import("../../src/db/tabs.js"); +const { + archiveTab, + createTab, + getDescendantIds, + getTab, + listOpenTabs, + resolveTabPrefix, + shortestUniquePrefix, + updateTabPositions, +} = await import("../../src/db/tabs.js"); beforeAll(() => { fakeDb = new FakeDatabase(); @@ -351,3 +383,36 @@ describe("shortestUniquePrefix", () => { expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd"); }); }); + +// --------------------------------------------------------------------------- +// updateTabPositions — drag-and-drop reorder persistence +// --------------------------------------------------------------------------- +describe("updateTabPositions", () => { + it("rewrites each tab's position to its index in the given order", () => { + createTab("a", "A"); // position 0 + createTab("b", "B"); // position 1 + createTab("c", "C"); // position 2 + + updateTabPositions(["c", "a", "b"]); + + // listOpenTabs orders by position → reflects the new order. + expect(listOpenTabs().map((t) => t.id)).toEqual(["c", "a", "b"]); + expect(getTab("c")?.position).toBe(0); + expect(getTab("a")?.position).toBe(1); + expect(getTab("b")?.position).toBe(2); + }); + + it("is a no-op for an empty list", () => { + createTab("a", "A"); + createTab("b", "B"); + updateTabPositions([]); + expect(listOpenTabs().map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("ignores ids that don't exist without throwing", () => { + createTab("a", "A"); + expect(() => updateTabPositions(["ghost", "a"])).not.toThrow(); + // "a" took index 1 in the requested order. + expect(getTab("a")?.position).toBe(1); + }); +}); 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); } @@ -75,12 +86,12 @@ function submit() { {/if} - {#each userTabs as tab (tab.id)} + {#each userTabs as tab, i (tab.id)}