diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 11:40:16 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 11:40:16 +0900 |
| commit | 7b345f132763fa6405ae858b74e46229629c19d9 (patch) | |
| tree | 4600200e5a92eccbe880f46b3760cf0b1217737d | |
| parent | 89ca80bac1e143a4ec5ba6e2e1d4998acce2553c (diff) | |
| download | dispatch-web-7b345f132763fa6405ae858b74e46229629c19d9.tar.gz dispatch-web-7b345f132763fa6405ae858b74e46229629c19d9.zip | |
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
| -rw-r--r-- | src/app.css | 12 | ||||
| -rw-r--r-- | src/app/App.svelte | 164 | ||||
| -rw-r--r-- | src/features/tabs/index.ts | 2 | ||||
| -rw-r--r-- | src/features/tabs/tabs.test.ts | 33 | ||||
| -rw-r--r-- | src/features/tabs/tabs.ts | 19 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 35 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabBar.svelte | 27 |
7 files changed, 239 insertions, 53 deletions
diff --git a/src/app.css b/src/app.css index 37131d3..5db1f25 100644 --- a/src/app.css +++ b/src/app.css @@ -6,3 +6,15 @@ @plugin "daisyui" { themes: dracula --default; } + +/* App shell fills the viewport and never scrolls/overflows at the page level — + the inner regions (tab strip, chat transcript) own their own scrolling. */ +html, +body, +#app { + height: 100%; +} + +body { + overflow: hidden; +} diff --git a/src/app/App.svelte b/src/app/App.svelte index 857a1e5..4ee071d 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -7,6 +7,12 @@ let { store }: { store: AppStore } = $props(); + // Right sidebar: open by default on wide screens (pushes the chat aside), + // closed by default on narrow screens (overlays the chat). Initial state is + // derived from the viewport width once; the hamburger toggles it thereafter. + const WIDE_BREAKPOINT = 1024; // Tailwind `lg` + let sidebarOpen = $state(typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true); + function handleSelect(surfaceId: string) { store.select(surfaceId); } @@ -24,34 +30,59 @@ } </script> -<main class="flex h-screen flex-col"> - <div class="flex items-center justify-between border-b border-base-300 px-4 py-2"> - <h1 class="text-lg font-bold">Dispatch</h1> - </div> - - {#if store.lastError} - <div role="alert" class="alert alert-error mx-4 mt-2"> - <strong>Error:</strong> - {store.lastError.message} +<main class="relative flex h-screen overflow-hidden"> + <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling + (below), so opening it shrinks this ENTIRE column — tab row included, which + slides the hamburger left. --> + <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]"> + <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with + a permanently seated hamburger pinned to the far right. --> + <div class="flex min-w-0 items-center"> + <TabBar + tabs={store.tabs} + activeConversationId={store.activeConversationId} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + /> + <button + class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" + aria-label="Toggle sidebar" + aria-expanded={sidebarOpen} + onclick={() => (sidebarOpen = !sidebarOpen)} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-5" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5" + /> + </svg> + </button> </div> - {/if} - {#if store.activeChat.error} - <div role="alert" class="alert alert-warning mx-4 mt-2"> - <strong>Chat error:</strong> - {store.activeChat.error} - </div> - {/if} + {#if store.lastError} + <div role="alert" class="alert alert-error mx-4 mt-2"> + <strong>Error:</strong> + {store.lastError.message} + </div> + {/if} - <TabBar - tabs={store.tabs} - activeConversationId={store.activeConversationId} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - /> + {#if store.activeChat.error} + <div role="alert" class="alert alert-warning mx-4 mt-2"> + <strong>Chat error:</strong> + {store.activeChat.error} + </div> + {/if} - <div class="flex flex-1 flex-col overflow-hidden"> <div class="flex items-center gap-2 px-4 py-2"> <ModelSelector models={store.models} @@ -60,37 +91,76 @@ /> </div> - <div class="flex-1 overflow-y-auto"> + <div class="relative min-w-0 flex-1 overflow-y-auto"> {#key store.activeConversationId} <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> {/key} + {#if store.activeChat.chunks.length === 0} + <div + class="pointer-events-none absolute inset-0 flex items-center justify-center" + aria-hidden="true" + > + <span class="select-none text-4xl font-bold opacity-10">Dispatch</span> + </div> + {/if} </div> <Composer onSend={handleSend} /> + + {#if store.catalog.length > 0} + <section class="border-t border-base-300 p-4"> + <h2 class="mb-2 text-sm font-semibold">Surfaces</h2> + <div class="flex flex-wrap gap-2"> + {#each store.catalog as entry (entry.id)} + <button + class="btn btn-sm" + class:btn-active={entry.id === store.selectedId} + aria-current={entry.id === store.selectedId ? "true" : undefined} + onclick={() => handleSelect(entry.id)} + > + {entry.title} + <span class="text-xs opacity-60">({entry.region})</span> + </button> + {/each} + </div> + </section> + {/if} + + {#if store.selectedSpec} + <section class="border-t border-base-300 p-4"> + <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} /> + </section> + {/if} </div> - {#if store.catalog.length > 0} - <section class="border-t border-base-300 p-4"> - <h2 class="mb-2 text-sm font-semibold">Surfaces</h2> - <div class="flex flex-wrap gap-2"> - {#each store.catalog as entry (entry.id)} - <button - class="btn btn-sm" - class:btn-active={entry.id === store.selectedId} - aria-current={entry.id === store.selectedId ? "true" : undefined} - onclick={() => handleSelect(entry.id)} - > - {entry.title} - <span class="text-xs opacity-60">({entry.region})</span> - </button> - {/each} - </div> - </section> - {/if} + <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so + opening it shrinks the whole left column (push). Below `lg` it overlays + (`max-lg:absolute`, full height) with a backdrop. --> + <aside + class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" + class:w-80={sidebarOpen} + class:w-0={!sidebarOpen} + > + <div + class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" + style="transform: translateX({sidebarOpen ? '0' : '100%'})" + > + <h2 class="text-sm font-semibold opacity-60">Sidebar</h2> + </div> + </aside> - {#if store.selectedSpec} - <section class="border-t border-base-300 p-4"> - <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} /> - </section> + <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> + {#if sidebarOpen} + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-20 bg-black/30 lg:hidden" + role="button" + tabindex="0" + aria-label="Close sidebar" + onclick={() => (sidebarOpen = false)} + onkeydown={(e) => { + if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false; + }} + ></div> {/if} </main> 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 @@ <script lang="ts"> import type { Tab } from "../tabs"; - import { isStuckToEnd } from "../tabs"; + import { isStuckToEnd, shortHandle } from "../tabs"; let { tabs, @@ -23,6 +23,15 @@ let scrollEl = $state<HTMLDivElement>(); let stuck = $state(false); + // Git-style short handle (shortest unique prefix) per open tab — the visible + // "tab ID". Derived from the set of open conversation ids; pure helper. + const handles = $derived.by(() => { + const ids = tabs.map((t) => t.conversationId); + const map = new Map<string, string>(); + for (const id of ids) map.set(id, shortHandle(id, ids)); + return map; + }); + function recompute(): void { const el = scrollEl; if (el === undefined) { @@ -55,11 +64,11 @@ }); </script> -<div bind:this={scrollEl} class="overflow-x-auto border-b border-base-300"> - <div class="tabs tabs-border min-w-max"> +<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto"> + <div class="tabs tabs-lift min-w-max"> {#each tabs as tab (tab.conversationId)} <div - class="tab" + class="tab flex w-48 shrink-0 items-center gap-1.5" class:tab-active={tab.conversationId === activeConversationId} role="tab" tabindex="0" @@ -68,9 +77,15 @@ if (e.key === "Enter") onSelect(tab.conversationId); }} > - <span class="max-w-[120px] truncate">{tab.title}</span> + <span + class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" + title="Tab ID" + > + {handles.get(tab.conversationId) ?? tab.conversationId} + </span> + <span class="min-w-0 flex-1 truncate text-left">{tab.title}</span> <button - class="btn btn-ghost btn-xs ml-1" + class="btn btn-ghost btn-xs shrink-0" aria-label="Close tab" onclick={(e) => { e.stopPropagation(); |
