summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 11:40:16 +0900
committerAdam Malczewski <[email protected]>2026-06-10 11:40:16 +0900
commit7b345f132763fa6405ae858b74e46229629c19d9 (patch)
tree4600200e5a92eccbe880f46b3760cf0b1217737d
parent89ca80bac1e143a4ec5ba6e2e1d4998acce2553c (diff)
downloaddispatch-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.css12
-rw-r--r--src/app/App.svelte164
-rw-r--r--src/features/tabs/index.ts2
-rw-r--r--src/features/tabs/tabs.test.ts33
-rw-r--r--src/features/tabs/tabs.ts19
-rw-r--r--src/features/tabs/ui.test.ts35
-rw-r--r--src/features/tabs/ui/TabBar.svelte27
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();