diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.svelte | 44 | ||||
| -rw-r--r-- | src/features/tabs/index.ts | 2 | ||||
| -rw-r--r-- | src/features/tabs/tabs.test.ts | 24 | ||||
| -rw-r--r-- | src/features/tabs/tabs.ts | 20 | ||||
| -rw-r--r-- | src/features/tabs/ui.test.ts | 117 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabBar.svelte | 177 | ||||
| -rw-r--r-- | src/features/tabs/ui/TabList.svelte | 145 |
7 files changed, 240 insertions, 289 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 09be947..9a17999 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -40,7 +40,7 @@ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue"; import { parseTodoPayload } from "../features/surface-host/logic/todo"; import TodoList from "../features/surface-host/ui/TodoList.svelte"; - import { manifest as tabsManifest, TabBar } from "../features/tabs"; + import { manifest as tabsManifest, TabList } from "../features/tabs"; import { manifest as viewsManifest, ViewSidebar } from "../features/views"; import { CwdField, @@ -99,6 +99,7 @@ // The view kinds offered in the sidebar's dropdown. Generic data — the // `viewContent` snippet below maps each kind id to its renderer. const viewKinds = [ + { id: "tabs", label: "Tabs" }, { id: "model", label: "Model" }, { id: "lsp", label: "Language Servers" }, { id: "mcp", label: "MCP Servers" }, @@ -111,8 +112,9 @@ { id: "settings", label: "Settings" }, ] as const; - // Default sidebar layout: just the Model view. - const DEFAULT_VIEWS: readonly string[] = ["model"]; + // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the + // Model view, so a fresh user sees their conversations + model controls. + const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"]; const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", { storage: untrack(() => store.storage), }); @@ -416,21 +418,12 @@ <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. --> + (below), so opening it shrinks this ENTIRE column. --> <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} - statusFor={(id) => store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> + <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so + the top row now holds only the build version + the sidebar toggle, + right-aligned. --> + <div class="flex items-center justify-end px-1"> <span class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30" title="Build version (git short hash)" @@ -585,7 +578,22 @@ {/if} {#snippet viewContent(kind: string)} - {#if kind === "model"} + {#if kind === "tabs"} + <!-- The conversation tab list (moved out of the top bar into the sidebar). + Re-mount per workspace so the filtered tab set + scroll reset cleanly + on a workspace switch. --> + {#key store.activeWorkspaceId} + <TabList + tabs={store.tabs} + activeConversationId={store.activeConversationId} + statusFor={(id) => store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + /> + {/key} + {:else if kind === "model"} <div class="flex flex-col gap-3"> <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 6ac90a3..7520215 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -14,7 +14,7 @@ export { } from "./tabs"; export type { TabsStorage, TabsStore } from "./tabs-store.svelte"; export { createTabsStore } from "./tabs-store.svelte"; -export { default as TabBar } from "./ui/TabBar.svelte"; +export { default as TabList } from "./ui/TabList.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index c31d2e7..ec93076 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -6,7 +6,6 @@ import { createTab, deriveTitle, initialState, - isStuckToEnd, MIN_HANDLE_LENGTH, newDraft, selectTab, @@ -194,29 +193,6 @@ describe("deriveTitle", () => { }); }); -describe("isStuckToEnd", () => { - it("is false when the strip does not overflow", () => { - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false); - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false); - }); - - it("is true when overflowing and scrolled to the left", () => { - expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true); - }); - - it("is true when overflowing and scrolled to the middle", () => { - expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true); - }); - - it("is false when overflowing but scrolled fully to the right", () => { - expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false); - }); - - it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => { - 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"]); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index bc7e30b..63cda35 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -87,26 +87,6 @@ export function activeTab(state: TabsState): Tab | null { return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; } -export interface ScrollMetrics { - readonly scrollLeft: number; - readonly clientWidth: number; - readonly scrollWidth: number; -} - -const STUCK_EPSILON = 1; - -/** - * True when a right-pinned sticky element is floating over scrolled content — the - * strip overflows horizontally AND is not scrolled fully to the right. When it is - * at rest (no overflow, or scrolled to the end so it sits at its natural position) - * this returns false. Pure: layout measurements in, boolean out. - */ -export function isStuckToEnd(m: ScrollMetrics): boolean { - const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON; - const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON; - return overflows && notAtEnd; -} - export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string { const trimmed = message.trim().replace(/\s+/g, " "); if (trimmed.length === 0) return DEFAULT_TITLE; diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 087b28c..ff342d0 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; -import TabBar from "./ui/TabBar.svelte"; +import TabList from "./ui/TabList.svelte"; const sampleTabs: readonly Tab[] = [ { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" }, @@ -10,9 +10,9 @@ const sampleTabs: readonly Tab[] = [ { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" }, ]; -describe("TabBar", () => { +describe("TabList", () => { it("renders one role=tab element per tab showing each title", () => { - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -29,8 +29,8 @@ describe("TabBar", () => { expect(tabs[2]).toHaveTextContent("Third"); }); - it("applies tab-active to the active tab only", () => { - render(TabBar, { + it("marks the active tab as aria-selected", () => { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c2", @@ -41,24 +41,9 @@ describe("TabBar", () => { }); const tabs = screen.getAllByRole("tab"); - expect(tabs[0]).not.toHaveClass("tab-active"); - expect(tabs[1]).toHaveClass("tab-active"); - expect(tabs[2]).not.toHaveClass("tab-active"); - }); - - it("applies tab-active to New chat button when activeConversationId is null", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: null, - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveClass("tab-active"); + expect(tabs[0]).toHaveAttribute("aria-selected", "false"); + expect(tabs[1]).toHaveAttribute("aria-selected", "true"); + expect(tabs[2]).toHaveAttribute("aria-selected", "false"); }); it("calls onSelect with the conversationId when a tab is clicked", async () => { @@ -66,7 +51,7 @@ describe("TabBar", () => { const onClose = vi.fn(); const user = userEvent.setup(); - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -91,7 +76,7 @@ describe("TabBar", () => { const onClose = vi.fn(); const user = userEvent.setup(); - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -115,7 +100,7 @@ describe("TabBar", () => { const onNewDraft = vi.fn(); const user = userEvent.setup(); - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -131,23 +116,8 @@ describe("TabBar", () => { expect(onNewDraft).toHaveBeenCalledTimes(1); }); - it("the New chat button has the sticky class", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveClass("sticky"); - }); - it("shows visible 'New Chat' text when activeConversationId is null", () => { - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: null, @@ -162,7 +132,7 @@ describe("TabBar", () => { }); it("does not show 'New Chat' text when a real tab is active", () => { - render(TabBar, { + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", @@ -181,7 +151,7 @@ describe("TabBar", () => { { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" }, { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" }, ]; - render(TabBar, { + render(TabList, { props: { tabs, activeConversationId: "3f9a1b2c-1111", @@ -195,19 +165,68 @@ describe("TabBar", () => { expect(screen.getByText("7c2d")).toBeInTheDocument(); }); - it("renders fixed-width tabs", () => { - render(TabBar, { + it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + // The scroll region containing the tab rows is a vertical flex column. + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBeGreaterThan(0); + const region = tabs[0]?.parentElement; + expect(region).toHaveClass("flex-col"); + }); + + it("caps the tab list region at 80vh so a long set scrolls internally", () => { + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + const region = tabs[0]?.parentElement; + expect(region).toHaveClass("max-h-[80vh]"); + expect(region).toHaveClass("overflow-y-auto"); + }); + + it("calls onRename when a tab title is double-clicked and committed with Enter", async () => { + const onRename = vi.fn(); + const user = userEvent.setup(); + + render(TabList, { props: { tabs: sampleTabs, activeConversationId: "c1", onSelect: vi.fn(), onClose: vi.fn(), onNewDraft: vi.fn(), + onRename, }, }); - for (const t of screen.getAllByRole("tab")) { - expect(t).toHaveClass("w-48"); - } + const titleButtons = screen.getAllByRole("button"); + // The inline-rename trigger is the title span (role=button) — find the one + // whose text matches the first tab's title. + const titleButton = titleButtons.find((b) => b.textContent === "First"); + if (!titleButton) throw new Error("title button not found"); + await user.dblClick(titleButton); + + const input = screen.getByRole("textbox"); + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + + expect(onRename).toHaveBeenCalledTimes(1); + expect(onRename).toHaveBeenCalledWith("c1", "Renamed"); }); }); diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte deleted file mode 100644 index 211fd5c..0000000 --- a/src/features/tabs/ui/TabBar.svelte +++ /dev/null @@ -1,177 +0,0 @@ -<script lang="ts"> - import type { Tab } from "../tabs"; - import { isStuckToEnd, shortHandle } from "../tabs"; - - let { - tabs, - activeConversationId, - statusFor, - onSelect, - onClose, - onNewDraft, - onRename, - }: { - tabs: readonly Tab[]; - activeConversationId: string | null; - /** Returns the conversation's lifecycle status, or undefined when unknown. */ - statusFor?: (conversationId: string) => string | undefined; - onSelect: (conversationId: string) => void; - onClose: (conversationId: string) => void; - onNewDraft: () => void; - onRename?: (conversationId: string, title: string) => void; - } = $props(); - - // The new-chat button is `position: sticky; right: 0`. It floats over the tabs - // only while the strip overflows and isn't scrolled fully right; we square its - // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) + - // DOM-measurement at the edge here. - 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) { - stuck = false; - return; - } - stuck = isStuckToEnd({ - scrollLeft: el.scrollLeft, - clientWidth: el.clientWidth, - scrollWidth: el.scrollWidth, - }); - } - - $effect(() => { - const el = scrollEl; - if (el === undefined) return; - // Re-evaluate when the tab set changes (overflow may appear/disappear). - void tabs; - recompute(); - - el.addEventListener("scroll", recompute, { passive: true }); - const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined; - ro?.observe(el); - - return () => { - el.removeEventListener("scroll", recompute); - ro?.disconnect(); - }; - }); - // Inline rename: double-click a tab's title to edit, Enter/blur to save. - let editingId = $state<string | null>(null); - let editValue = $state(""); - let editEl = $state<HTMLInputElement>(); - - function startRename(tab: Tab): void { - if (onRename === undefined) return; - editingId = tab.conversationId; - editValue = tab.title; - // Focus the input after it renders. - queueMicrotask(() => editEl?.focus()); - } - - function commitRename(): void { - const id = editingId; - if (id !== null && onRename !== undefined) { - const trimmed = editValue.trim(); - if (trimmed.length > 0) onRename(id, trimmed); - } - editingId = null; - } - - function cancelRename(): void { - editingId = null; - } -</script> - -<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 flex w-48 shrink-0 items-center gap-1.5" - class:tab-active={tab.conversationId === activeConversationId} - role="tab" - tabindex="0" - onclick={() => onSelect(tab.conversationId)} - onkeydown={(e) => { - if (e.key === "Enter") onSelect(tab.conversationId); - }} - > - <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> - {#if editingId === tab.conversationId} - <input - bind:this={editEl} - bind:value={editValue} - class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelRename(); - } - }} - onblur={commitRename} - /> - {:else} - <span - class="min-w-0 flex-1 cursor-pointer truncate text-left" - role="button" - tabindex="-1" - title={tab.title} - ondblclick={(e) => { - e.stopPropagation(); - startRename(tab); - }} - > - {tab.title} - </span> - {/if} - {#if statusFor?.(tab.conversationId) === "active"} - <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span> - {/if} - <button - class="btn btn-ghost btn-xs shrink-0" - aria-label="Close tab" - onclick={(e) => { - e.stopPropagation(); - onClose(tab.conversationId); - }} - > - × - </button> - </div> - {/each} - <button - class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck - ? '!rounded-se-none !rounded-ee-none' - : ''}" - class:tab-active={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - <span class="max-w-[120px] truncate">New Chat</span> - <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span> - {:else} - + - {/if} - </button> - </div> -</div> diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte new file mode 100644 index 0000000..efef4fa --- /dev/null +++ b/src/features/tabs/ui/TabList.svelte @@ -0,0 +1,145 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + import { shortHandle } from "../tabs"; + + let { + tabs, + activeConversationId, + statusFor, + onSelect, + onClose, + onNewDraft, + onRename, + }: { + tabs: readonly Tab[]; + activeConversationId: string | null; + /** Returns the conversation's lifecycle status, or undefined when unknown. */ + statusFor?: (conversationId: string) => string | undefined; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onNewDraft: () => void; + onRename?: (conversationId: string, title: string) => void; + } = $props(); + + // 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; + }); + + // Inline rename: double-click a tab's title to edit, Enter/blur to save. + let editingId = $state<string | null>(null); + let editValue = $state(""); + let editEl = $state<HTMLInputElement>(); + + function startRename(tab: Tab): void { + if (onRename === undefined) return; + editingId = tab.conversationId; + editValue = tab.title; + // Focus the input after it renders. + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + const id = editingId; + if (id !== null && onRename !== undefined) { + const trimmed = editValue.trim(); + if (trimmed.length > 0) onRename(id, trimmed); + } + editingId = null; + } + + function cancelRename(): void { + editingId = null; + } +</script> + +<div class="flex flex-col gap-2"> + <!-- Single-column vertical tab list. Capped at 80% of the viewport height + so a long tab set scrolls inside this region instead of growing the + whole sidebar. --> + <div class="flex max-h-[80vh] flex-col gap-1 overflow-y-auto pr-1"> + {#each tabs as tab (tab.conversationId)} + <div + class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-200" + class:bg-base-300={tab.conversationId === activeConversationId} + role="tab" + tabindex="0" + aria-selected={tab.conversationId === activeConversationId} + title={tab.title} + onclick={() => onSelect(tab.conversationId)} + onkeydown={(e) => { + if (e.key === "Enter") onSelect(tab.conversationId); + }} + > + <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> + {#if editingId === tab.conversationId} + <input + bind:this={editEl} + bind:value={editValue} + class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelRename(); + } + }} + onblur={commitRename} + /> + {:else} + <span + class="min-w-0 flex-1 cursor-pointer truncate text-left" + role="button" + tabindex="-1" + title={tab.title} + ondblclick={(e) => { + e.stopPropagation(); + startRename(tab); + }} + > + {tab.title} + </span> + {/if} + {#if statusFor?.(tab.conversationId) === "active"} + <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span> + {/if} + <button + class="btn btn-ghost btn-xs shrink-0" + aria-label="Close tab" + onclick={(e) => { + e.stopPropagation(); + onClose(tab.conversationId); + }} + > + × + </button> + </div> + {/each} + </div> + + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> +</div> |
