From 7b345f132763fa6405ae858b74e46229629c19d9 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 10 Jun 2026 11:40:16 +0900 Subject: feat(tabs,app): tab id handles, fixed-width tabs-lift, slim shell + full-height sidebar Tabs: - short-handle ID badge per tab (shortest unique conversationId prefix, min 4) - fixed-width (w-48) tabs with tabs-lift folder borders Shell (composition root): - drop the Dispatch title bar; tabs sit at the very top with a 5px gap - big faded "Dispatch" watermark centered on an empty chat - collapsible right sidebar (empty shell) spanning full window height: a permanently right-pinned hamburger in the tab row toggles it; in-flow push that shrinks the whole left column (tabs included) at >=lg, overlay + backdrop below lg; open-by-default on wide / closed on narrow - main is overflow-hidden with a min-w-0 shrink chain; app.css pins html/body/#app height + body overflow hidden so the page never overflows --- src/features/tabs/index.ts | 2 ++ src/features/tabs/tabs.test.ts | 33 +++++++++++++++++++++++++++++++++ src/features/tabs/tabs.ts | 19 +++++++++++++++++++ src/features/tabs/ui.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/features/tabs/ui/TabBar.svelte | 27 +++++++++++++++++++++------ 5 files changed, 110 insertions(+), 6 deletions(-) (limited to 'src/features/tabs') diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 835788a..50de62a 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -5,10 +5,12 @@ export { createTab, deriveTitle, initialState, + MIN_HANDLE_LENGTH, newDraft, selectTab, setModel, setTitle, + shortHandle, } from "./tabs"; export type { TabsStorage, TabsStore } from "./tabs-store.svelte"; export { createTabsStore } from "./tabs-store.svelte"; diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index 5effa9b..3c2a8c2 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -7,10 +7,12 @@ import { deriveTitle, initialState, isStuckToEnd, + MIN_HANDLE_LENGTH, newDraft, selectTab, setModel, setTitle, + shortHandle, } from "./tabs"; const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({ @@ -213,3 +215,34 @@ describe("isStuckToEnd", () => { expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false); }); }); + +describe("shortHandle", () => { + it("uses the minimum length when the id is unique", () => { + const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]); + expect(h).toBe("3f9a"); + expect(h.length).toBe(MIN_HANDLE_LENGTH); + }); + + it("grows the prefix until unique among open tabs", () => { + // two ids share the first 5 chars → handle grows to 6 to disambiguate + expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1"); + expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2"); + }); + + it("shrinks back to the minimum when the colliding sibling is gone", () => { + expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd"); + }); + + it("ignores the id itself when present in the list", () => { + expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead"); + }); + + it("returns the whole id when shorter than the minimum length", () => { + expect(shortHandle("ab", ["ab", "cd"])).toBe("ab"); + }); + + it("falls back to the full id when one id is a prefix of another", () => { + // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it + expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index a4db6f3..61ae58e 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -92,3 +92,22 @@ export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LEN if (trimmed.length <= max) return trimmed; return `${trimmed.slice(0, max)}\u2026`; } + +/** Minimum length of a tab handle (git-style short id). */ +export const MIN_HANDLE_LENGTH = 4; + +/** + * The short "handle" shown on a tab: the shortest prefix of `conversationId` + * (at least `MIN_HANDLE_LENGTH` chars) that is unique among all open tabs — a + * git-style short id. Grows by a char only when another open tab shares the + * prefix, and shrinks back when that sibling closes. Pure: the id + every open + * id in, the handle string out. (`allIds` may include `conversationId` itself.) + */ +export function shortHandle(conversationId: string, allIds: readonly string[]): string { + const others = allIds.filter((id) => id !== conversationId); + for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) { + const candidate = conversationId.slice(0, len); + if (!others.some((id) => id.startsWith(candidate))) return candidate; + } + return conversationId; +} diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 1ae18c8..6cd66bd 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -175,4 +175,39 @@ describe("TabBar", () => { const newChat = screen.getByRole("button", { name: "New chat" }); expect(newChat).not.toHaveTextContent("New Chat"); }); + + it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => { + const tabs: readonly Tab[] = [ + { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha" }, + { conversationId: "7c2db4e5-2222", model: "m", title: "Beta" }, + ]; + render(TabBar, { + props: { + tabs, + activeConversationId: "3f9a1b2c-1111", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + expect(screen.getByText("3f9a")).toBeInTheDocument(); + expect(screen.getByText("7c2d")).toBeInTheDocument(); + }); + + it("renders fixed-width tabs", () => { + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + for (const t of screen.getAllByRole("tab")) { + expect(t).toHaveClass("w-48"); + } + }); }); diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte index eb5b5e5..812a663 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabBar.svelte @@ -1,6 +1,6 @@ -
-
+
+
{#each tabs as tab (tab.conversationId)}