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/app/App.svelte | 44 +++++---- 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 +++++++++++++++++++++++++++++ 7 files changed, 240 insertions(+), 289 deletions(-) delete mode 100644 src/features/tabs/ui/TabBar.svelte create mode 100644 src/features/tabs/ui/TabList.svelte 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("dispatch.sidebar.views", { storage: untrack(() => store.storage), }); @@ -416,21 +418,12 @@
+ (below), so opening it shrinks this ENTIRE column. -->
- -
- store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> + +
+ {#key store.activeWorkspaceId} + 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"}
+
+ {#each tabs as tab (tab.conversationId)} + + {/each} +
+ + +
-- cgit v1.2.3 From 6bd8f4718b44d06d116d02d9538214c8450b1c8d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 16:08:10 +0900 Subject: feat(sidebar-tabs): add active tab title to top bar (New Tab for unstarted chats) --- src/app/App.svelte | 27 ++++++++++++++++++++++++--- src/app/App.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 9a17999..1bb5689 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -216,6 +216,18 @@ return parseTodoPayload(field.payload); }); + // Top-bar title: the active tab's title, or "New Tab" when no tab is active + // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no + // tab exists). Pure-derived from the (workspace-filtered) tab set + the + // active id; reflects whichever tab is selected in the sidebar's Tabs view. + const NEW_TAB_TITLE = "New Tab"; + const topBarTitle = $derived.by(() => { + const id = store.activeConversationId; + if (id === null) return NEW_TAB_TITLE; + const tab = store.tabs.find((t) => t.conversationId === id); + return tab?.title ?? NEW_TAB_TITLE; + }); + // Conversation/tab switch → snap to the bottom of the new transcript. $effect(() => { void store.activeConversationId; @@ -421,9 +433,18 @@ (below), so opening it shrinks this ENTIRE column. -->
-
+ the top row now shows the active tab's title on the left (or "New Tab" + for an unstarted draft), with the build version + sidebar toggle on + the right. --> +
+ + {topBarTitle} + { store.dispose(); }); + it("shows 'New Tab' in the top bar for an unstarted draft (no active tab)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + expect(store.activeConversationId).toBeNull(); + expect(screen.getByTestId("top-bar-title")).toHaveTextContent("New Tab"); + + store.dispose(); + }); + + it("shows the active tab's title in the top bar once a tab is started", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // Promote draft → tab: the tab's title is derived from the first message. + store.send("Refactor the auth module"); + expect(store.activeConversationId).not.toBeNull(); + + // The top-bar title updates reactively; findByTestId awaits the flush. + expect(await screen.findByTestId("top-bar-title")).toHaveTextContent( + "Refactor the auth module", + ); + + store.dispose(); + }); + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { const ws = fakeSocket(); const store = createAppStore({ -- cgit v1.2.3 From 07bb2ca4a5c17101f6f0e469564963e5e922399e Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 16:13:34 +0900 Subject: feat(sidebar-tabs): add 2px horizontal spacing to top-bar title --- src/app/App.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 1bb5689..04be746 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -438,7 +438,7 @@ the right. -->
Date: Sat, 27 Jun 2026 16:15:09 +0900 Subject: fix(sidebar-tabs): inset top-bar title 8px left to match parent px-2 spacing --- src/app/App.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 04be746..b52c414 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -438,7 +438,7 @@ the right. -->
Date: Sat, 27 Jun 2026 16:23:33 +0900 Subject: feat(sidebar-tabs): replace hamburger with directional double-chevron toggle --- src/app/App.svelte | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index b52c414..290f27b 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -453,7 +453,7 @@
-- cgit v1.2.3 From d3c1f96cbcaea4cadb2432a93e7ba305e938735e Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 16:26:28 +0900 Subject: feat(sidebar-tabs): remove sidebar left border --- src/app/App.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 290f27b..31d0155 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -557,7 +557,7 @@ class:w-0={!sidebarOpen} >
-- cgit v1.2.3 From 814544e1924e736c924de6d4d5b3c404ca036999 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 16:29:48 +0900 Subject: fix(sidebar-tabs): drop sidebar left padding so it shares the chat gutter --- src/app/App.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 31d0155..9926824 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -557,7 +557,7 @@ class:w-0={!sidebarOpen} >
-- 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(-) 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(-) 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(-) 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}
{/if} -
+
{#key store.activeConversationId} diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index e72f639..b49f7b4 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -210,7 +210,7 @@ {/if} {/snippet} -
+
{#if hasEarlier && onShowEarlier}
-- cgit v1.2.3 From e1f6986c32767fea445eedffece488f10a73b3d2 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 17:15:02 +0900 Subject: feat(sidebar-tabs): prefix build hash with "build: " --- src/app/App.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/App.svelte b/src/app/App.svelte index 2bcf346..f8e6e5f 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -449,7 +449,7 @@ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30" title="Build version (git short hash)" > - {__APP_VERSION__} + build: {__APP_VERSION__}
{#if cwdError} diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index 0736efb..0d03b8e 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -119,16 +119,20 @@ describe("WorkspaceCard", () => { expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); }); - it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => { + it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => { + const user = userEvent.setup(); const store = fakeStore() as unknown as WorkspaceStore; const onNavigate = vi.fn(); render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } }); const open = screen.getByRole("link", { name: "Open" }); - // Native new-tab link: href points at the workspace, and target="_blank" - // opens it in a new browser tab rather than client-side navigating. + // Still a real link (progressive enhancement): href points at the workspace. expect(open).toHaveAttribute("href", "/my-ws"); - expect(open).toHaveAttribute("target", "_blank"); - expect(open.getAttribute("rel") ?? "").toMatch(/noopener/); + // But it no longer opens a new browser tab. + expect(open).not.toHaveAttribute("target", "_blank"); + // Clicking navigates in-place via the SPA callback. + await user.click(open); + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("/my-ws"); }); }); -- cgit v1.2.3