diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 18:26:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 18:26:29 +0900 |
| commit | e81df4c4936549fa674584c076e9e59fa4c920d5 (patch) | |
| tree | d01ee2c17e069a5d05528d27fafd7a9de23e2801 /src/app | |
| parent | f79e3fcd846f24582ea8c536cf55f1f72d75d967 (diff) | |
| parent | 5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1 (diff) | |
| download | dispatch-web-e81df4c4936549fa674584c076e9e59fa4c920d5.tar.gz dispatch-web-e81df4c4936549fa674584c076e9e59fa4c920d5.zip | |
Merge branch 'dev' into feature/provider-concurrency
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 121 | ||||
| -rw-r--r-- | src/app/App.test.ts | 86 |
2 files changed, 167 insertions, 40 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index e0f64eb..5aa103d 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, @@ -90,7 +90,7 @@ import { createLocalStore } from "../adapters/local-storage"; import { untrack } from "svelte"; - let { store }: { store: AppStore } = $props(); + let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props(); // The backend's conversation-scoped cache-warming surface. Referenced by id at // the composition root (sanctioned discovery-by-id) to give it a dedicated view @@ -107,6 +107,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" }, @@ -120,8 +121,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), }); @@ -224,6 +226,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; @@ -438,30 +452,56 @@ <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 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. --> + <div class="flex items-center justify-between gap-2 px-2"> + <span + class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70" + data-testid="top-bar-title" + title={topBarTitle} + aria-label="Active conversation title" + > + {topBarTitle} + </span> + <a + href="/" + class="btn btn-ghost btn-sm shrink-0 px-2" + aria-label="Back to dashboard" + title="Back to dashboard" + onclick={(e) => { + e.preventDefault(); + onNavigate("/"); + }} + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="2" + stroke="currentColor" + class="size-4" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25" + /> + </svg> + </a> <span 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__} </span> <button class="btn btn-square btn-ghost btn-sm mx-1 shrink-0" - aria-label="Toggle sidebar" + aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"} aria-expanded={sidebarOpen} onclick={() => (sidebarOpen = !sidebarOpen)} > @@ -474,11 +514,21 @@ 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" - /> + {#if sidebarOpen} + <!-- Sidebar open → chevrons point right --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5" + /> + {:else} + <!-- Sidebar closed → chevrons point left --> + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5" + /> + {/if} </svg> </button> </div> @@ -497,7 +547,7 @@ </div> {/if} - <div class="relative min-h-0 min-w-0 flex-1"> + <div class="relative min-h-0 min-w-0 flex-1 pr-4"> <div bind:this={transcriptEl} class="h-full overflow-y-auto"> <div bind:this={transcriptContentEl}> {#key store.activeConversationId} @@ -555,7 +605,7 @@ 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" + class="flex h-full w-80 flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} /> @@ -607,7 +657,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/app/App.test.ts b/src/app/App.test.ts index 7c0a851..dcdcb9b 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -2,7 +2,7 @@ import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contrac import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; import App from "./App.svelte"; import { createAppStore } from "./store.svelte"; @@ -127,7 +127,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); @@ -136,6 +136,68 @@ describe("App component interaction tests", () => { 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, onNavigate: vi.fn() } }); + + 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, onNavigate: vi.fn() } }); + + // 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("the dashboard home button navigates to '/' (back to the workspaces home)", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const onNavigate = vi.fn(); + render(App, { props: { store, onNavigate } }); + + const homeBtn = screen.getByRole("link", { name: "Back to dashboard" }); + expect(homeBtn).toHaveAttribute("href", "/"); + await userEvent.setup().click(homeBtn); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith("/"); + + store.dispose(); + }); + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -154,7 +216,7 @@ describe("App component interaction tests", () => { ], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const subscribed = sentMessages(ws) .filter((m: { type: string }) => m.type === "subscribe") @@ -182,7 +244,7 @@ describe("App component interaction tests", () => { ], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // No interaction: specs arrive and both surfaces render expanded. ws.feedSurfaceMessage({ @@ -221,7 +283,7 @@ describe("App component interaction tests", () => { message: "Something went wrong", }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const alert = screen.getByRole("alert"); expect(alert).toHaveTextContent("Something went wrong"); @@ -243,7 +305,7 @@ describe("App component interaction tests", () => { catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const user = userEvent.setup(); // Surface is auto-subscribed; its spec arrives and renders expanded. @@ -290,7 +352,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); const user = userEvent.setup(); const textarea = screen.getByRole("textbox", { name: "Message input" }); @@ -323,7 +385,7 @@ describe("App component interaction tests", () => { store.send("test"); const convId = activeConversationId(store); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); ws.feedServerMessage({ type: "chat.delta", @@ -363,7 +425,7 @@ describe("App component interaction tests", () => { catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // Auto-subscribed; the custom-table spec arrives and renders expanded. ws.feedSurfaceMessage({ @@ -401,7 +463,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); @@ -451,7 +513,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // The modal should appear with the error text const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 }); @@ -477,7 +539,7 @@ describe("App component interaction tests", () => { }); ws.resolveOpen(); - render(App, { props: { store } }); + render(App, { props: { store, onNavigate: vi.fn() } }); // Wait a tick for boot async to settle await new Promise((resolve) => setTimeout(resolve, 200)); |
