summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 18:26:29 +0900
committerAdam Malczewski <[email protected]>2026-06-27 18:26:29 +0900
commite81df4c4936549fa674584c076e9e59fa4c920d5 (patch)
treed01ee2c17e069a5d05528d27fafd7a9de23e2801 /src/app
parentf79e3fcd846f24582ea8c536cf55f1f72d75d967 (diff)
parent5bd0b63a7f0cafd53fbd4ba287f8e2cf2b59a4a1 (diff)
downloaddispatch-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.svelte121
-rw-r--r--src/app/App.test.ts86
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));