From 0e0601817712033b3247695646acd22d6496330a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 02:17:24 +0900 Subject: feat(sidebar-tabs): move tab bar from top into sidebar as vertical list --- src/features/tabs/index.ts | 2 +- src/features/tabs/tabs.test.ts | 24 ----- src/features/tabs/tabs.ts | 20 ---- src/features/tabs/ui.test.ts | 117 ++++++++++++++---------- src/features/tabs/ui/TabBar.svelte | 177 ------------------------------------ src/features/tabs/ui/TabList.svelte | 145 +++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 271 deletions(-) delete mode 100644 src/features/tabs/ui/TabBar.svelte create mode 100644 src/features/tabs/ui/TabList.svelte (limited to 'src/features/tabs') 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 @@ - - -
-
- {#each tabs as tab (tab.conversationId)} - - {/each} - -
-
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 @@ + + +
+ +
+ {#each tabs as tab (tab.conversationId)} + + {/each} +
+ + +
-- cgit v1.2.3 From eb1fbf136dfce5099afbf1a2f1411a672656a59a Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 16:41:57 +0900 Subject: feat(sidebar-tabs): click tab ID badge to copy conversation id --- src/features/tabs/ui.test.ts | 32 ++++++++++++++++++- src/features/tabs/ui/TabList.svelte | 62 ++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 6 deletions(-) (limited to 'src/features/tabs') diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index ff342d0..57f9bc1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/svelte"; +import { fireEvent, 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"; @@ -229,4 +229,34 @@ describe("TabList", () => { expect(onRename).toHaveBeenCalledTimes(1); expect(onRename).toHaveBeenCalledWith("c1", "Renamed"); }); + + it("copies the full conversation id to the clipboard and shows 'Copied!' when the ID badge is clicked", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onSelect = vi.fn(); + + render(TabList, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" }); + await fireEvent.click(idBadge); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText).toHaveBeenCalledWith("c1"); + // The badge briefly shows a "Copied!" confirmation. + expect(idBadge).toHaveTextContent("Copied!"); + // Clicking the ID must NOT switch tabs (the badge stops propagation). + expect(onSelect).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index efef4fa..40f95cd 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -55,6 +55,46 @@ function cancelRename(): void { editingId = null; } + + // Click-to-copy the agent (conversation) id: clicking the ID badge copies the + // FULL conversationId to the clipboard (the stable, useful id — the badge + // only shows a short prefix) and briefly highlights the badge as feedback. + // Clipboard is the edge effect; absent (insecure context) → select the badge + // text so the user can Ctrl+C manually. + let copiedId = $state(null); + let copyTimer: ReturnType | undefined; + + async function copyId(conversationId: string, el: HTMLElement): Promise { + const clipboard = navigator.clipboard; + if (clipboard === undefined) { + selectText(el); + flashCopied(conversationId); + return; + } + try { + await clipboard.writeText(conversationId); + flashCopied(conversationId); + } catch { + selectText(el); + flashCopied(conversationId); + } + } + + function flashCopied(conversationId: string): void { + copiedId = conversationId; + clearTimeout(copyTimer); + copyTimer = setTimeout(() => { + copiedId = null; + }, 1200); + } + + function selectText(el: HTMLElement): void { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }
@@ -75,12 +115,24 @@ if (e.key === "Enter") onSelect(tab.conversationId); }} > - { + e.stopPropagation(); + void copyId(tab.conversationId, e.currentTarget); + }} > - {handles.get(tab.conversationId) ?? tab.conversationId} - + {copiedId === tab.conversationId + ? "Copied!" + : (handles.get(tab.conversationId) ?? tab.conversationId)} + {#if editingId === tab.conversationId} Date: Sat, 27 Jun 2026 16:54:20 +0900 Subject: fix(sidebar-tabs): use text-selection highlight as copy indicator instead of swapping text --- src/features/tabs/ui.test.ts | 11 +++++++--- src/features/tabs/ui/TabList.svelte | 40 ++++++++++--------------------------- 2 files changed, 18 insertions(+), 33 deletions(-) (limited to 'src/features/tabs') diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 57f9bc1..9af4cd1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -230,7 +230,7 @@ describe("TabList", () => { expect(onRename).toHaveBeenCalledWith("c1", "Renamed"); }); - it("copies the full conversation id to the clipboard and shows 'Copied!' when the ID badge is clicked", async () => { + it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { value: { writeText }, @@ -254,8 +254,13 @@ describe("TabList", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(writeText).toHaveBeenCalledWith("c1"); - // The badge briefly shows a "Copied!" confirmation. - expect(idBadge).toHaveTextContent("Copied!"); + // The badge text is NOT swapped (no width shift) — the highlight (selection) + // is the only indicator. + expect(idBadge).toHaveTextContent("c1"); + expect(idBadge).not.toHaveTextContent("Copied"); + // The badge text is selected (highlighted) as the copy indicator. + const selection = window.getSelection(); + expect(selection?.toString()).toBe("c1"); // Clicking the ID must NOT switch tabs (the badge stops propagation). expect(onSelect).not.toHaveBeenCalled(); }); diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index 40f95cd..031743c 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -58,36 +58,21 @@ // Click-to-copy the agent (conversation) id: clicking the ID badge copies the // FULL conversationId to the clipboard (the stable, useful id — the badge - // only shows a short prefix) and briefly highlights the badge as feedback. - // Clipboard is the edge effect; absent (insecure context) → select the badge - // text so the user can Ctrl+C manually. - let copiedId = $state(null); - let copyTimer: ReturnType | undefined; - + // only shows a short prefix) and highlights the badge text as feedback. The + // highlight (text selection) is the indicator — no text is swapped, so the + // badge keeps a stable width. If the clipboard write fails, the selection is + // already in place so the user can Ctrl+C the text manually. async function copyId(conversationId: string, el: HTMLElement): Promise { + selectText(el); const clipboard = navigator.clipboard; - if (clipboard === undefined) { - selectText(el); - flashCopied(conversationId); - return; - } + if (clipboard === undefined) return; try { await clipboard.writeText(conversationId); - flashCopied(conversationId); } catch { - selectText(el); - flashCopied(conversationId); + // Selection already lets the user copy manually. } } - function flashCopied(conversationId: string): void { - copiedId = conversationId; - clearTimeout(copyTimer); - copyTimer = setTimeout(() => { - copiedId = null; - }, 1200); - } - function selectText(el: HTMLElement): void { const range = document.createRange(); range.selectNodeContents(el); @@ -117,21 +102,16 @@ > {#if editingId === tab.conversationId} Date: Sat, 27 Jun 2026 16:59:13 +0900 Subject: feat(sidebar-tabs): use daisyui loading-dots for chat generating spinners --- src/features/chat/ui/Composer.svelte | 2 +- src/features/tabs/ui/TabList.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src/features/tabs') diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 96b4b3a..2f3d820 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -136,7 +136,7 @@
{#if status === "running"} - + {:else if status === "error"} {/if} {#if statusFor?.(tab.conversationId) === "active"} - + {/if}