summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:43:16 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:43:16 +0900
commit3ebcd49c404ed287a97af159ac8adfa63d572849 (patch)
treef8245ed28530a8e96046221eb1d7eca47d508dc8 /packages/frontend/tests
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-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/tests')
-rw-r--r--packages/frontend/tests/chat-store.test.ts154
1 files changed, 154 insertions, 0 deletions
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
index c0763cd..a0d4ead 100644
--- a/packages/frontend/tests/chat-store.test.ts
+++ b/packages/frontend/tests/chat-store.test.ts
@@ -1972,3 +1972,157 @@ describe("tabStore — chunk-native eviction / pagination / reconcile", () => {
expect(tab?.live.some((m) => m.turnId === "turn-a")).toBe(false);
});
});
+
+describe("tabStore — tab reorder", () => {
+ it("reorders user tabs and persists the new order", async () => {
+ const calls: Array<{ url: string; body: string }> = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string, opts?: { body?: string }) => {
+ calls.push({ url, body: opts?.body ?? "" });
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
+ }),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ const b = await store.createNewTab();
+ const c = await store.createNewTab();
+ expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id, c.id]);
+
+ // Move the last tab to the front.
+ store.reorderTabs([c.id, a.id, b.id]);
+ expect(store.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
+
+ const reorderCall = calls.find((call) => call.url.endsWith("/tabs/reorder"));
+ expect(reorderCall).toBeTruthy();
+ expect(JSON.parse(reorderCall?.body ?? "{}")).toEqual({ ids: [c.id, a.id, b.id] });
+ });
+
+ it("ignores a stale order that doesn't cover all user tabs", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ const b = await store.createNewTab();
+ store.reorderTabs([a.id]); // missing b → no-op
+ expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id]);
+ });
+
+ it("keeps subagent tabs after the user tabs when reordering", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ const b = await store.createNewTab();
+ // A subagent tab arrives via WS (parentTabId set).
+ store.handleEvent({
+ type: "tab-created",
+ id: "sub",
+ title: "Sub",
+ keyId: null,
+ modelId: null,
+ parentTabId: a.id,
+ });
+ store.reorderTabs([b.id, a.id]);
+ const ids = store.tabs.map((t) => t.id);
+ expect(ids).toEqual([b.id, a.id, "sub"]);
+ });
+});
+
+describe("tabStore — rename + auto-title guard", () => {
+ it("renameTab sets the title and persists it", async () => {
+ const calls: Array<{ url: string; method?: string; body: string }> = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string, opts?: { method?: string; body?: string }) => {
+ calls.push({ url, method: opts?.method, body: opts?.body ?? "" });
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
+ }),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ store.renameTab(a.id, " My Tab ");
+ expect(store.tabs[0]?.title).toBe("My Tab");
+ expect(store.tabs[0]?.manualTitle).toBe(true);
+ const patch = calls.find(
+ (call) => call.url.endsWith(`/tabs/${a.id}`) && call.method === "PATCH",
+ );
+ expect(JSON.parse(patch?.body ?? "{}")).toEqual({ title: "My Tab" });
+ });
+
+ it("renameTab ignores an empty/whitespace name", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ store.renameTab(a.id, " ");
+ expect(store.tabs[0]?.title).toBe("New Tab");
+ expect(store.tabs[0]?.manualTitle).toBe(false);
+ });
+
+ it("sendMessage does NOT auto-title a manually renamed tab", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })),
+ );
+ const store = createTabStore();
+ await store.createNewTab();
+ store.renameTab(store.tabs[0]?.id ?? "", "Keep Me");
+ await store.sendMessage("hello there this is the first message");
+ expect(store.tabs[0]?.title).toBe("Keep Me");
+ });
+
+ it("sendMessage still auto-titles a tab that was never renamed", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })),
+ );
+ const store = createTabStore();
+ await store.createNewTab();
+ await store.sendMessage("first message becomes the title");
+ expect(store.tabs[0]?.title).toBe("first message becomes the title");
+ expect(store.tabs[0]?.manualTitle).toBe(false);
+ });
+});
+
+describe("tabStore — per-tab chat input draft", () => {
+ it("stores drafts per tab and restores them on switch", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ const b = await store.createNewTab();
+
+ store.switchTab(a.id);
+ store.setDraft(a.id, "draft for A");
+ store.switchTab(b.id);
+ store.setDraft(b.id, "draft for B");
+
+ // Active tab is B → its draft is exposed.
+ expect(store.activeTab?.draft).toBe("draft for B");
+ // Switching back to A restores A's draft without clobbering B's.
+ store.switchTab(a.id);
+ expect(store.activeTab?.draft).toBe("draft for A");
+ expect(store.tabs.find((t) => t.id === b.id)?.draft).toBe("draft for B");
+ });
+
+ it("new tabs start with an empty draft and setDraft no-ops for unknown tabs", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
+ );
+ const store = createTabStore();
+ const a = await store.createNewTab();
+ expect(a.draft).toBe("");
+ store.setDraft("nope", "ignored"); // unknown tab → no throw, no effect
+ expect(store.tabs.every((t) => t.draft === "")).toBe(true);
+ });
+});