From c333fcec32b1f90bf0da6bb14d2609c20e38a74f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 22:21:55 +0900 Subject: style: switch from tabs to 2-space indentation (incl. svelte) --- src/App.svelte | 122 +- src/adapters/history/index.test.ts | 158 +- src/adapters/history/index.ts | 116 +- src/adapters/idb/index.test.ts | 160 +- src/adapters/idb/index.ts | 292 +- src/adapters/local-storage/index.test.ts | 224 +- src/adapters/local-storage/index.ts | 86 +- src/adapters/ws/index.test.ts | 790 ++--- src/adapters/ws/index.ts | 198 +- src/adapters/ws/logic.test.ts | 742 ++--- src/adapters/ws/logic.ts | 286 +- src/app.css | 204 +- src/app/App.svelte | 1199 ++++---- src/app/App.test.ts | 984 +++---- src/app/ErrorModal.svelte | 271 +- src/app/resolve-http-url.test.ts | 102 +- src/app/resolve-http-url.ts | 30 +- src/app/resolve-ws-url.test.ts | 96 +- src/app/resolve-ws-url.ts | 28 +- src/app/store.svelte.ts | 2918 +++++++++---------- src/app/store.test.ts | 2174 +++++++------- src/app/uuid.test.ts | 44 +- src/app/uuid.ts | 92 +- src/components/Table.svelte | 74 +- src/components/Table.test.ts | 54 +- src/core/chunks/groups.test.ts | 204 +- src/core/chunks/groups.ts | 118 +- src/core/chunks/index.ts | 38 +- src/core/chunks/reducer.test.ts | 1546 +++++----- src/core/chunks/reducer.ts | 488 ++-- src/core/chunks/retry-banner.test.ts | 102 +- src/core/chunks/retry-banner.ts | 38 +- src/core/chunks/selectors.ts | 72 +- src/core/chunks/trim.test.ts | 404 +-- src/core/chunks/trim.ts | 160 +- src/core/chunks/types.ts | 120 +- src/core/metrics/format.test.ts | 658 ++--- src/core/metrics/format.ts | 158 +- src/core/metrics/index.ts | 48 +- src/core/metrics/place.test.ts | 976 +++---- src/core/metrics/place.ts | 448 +-- src/core/metrics/reducer.test.ts | 822 +++--- src/core/metrics/reducer.ts | 362 +-- src/core/metrics/types.ts | 114 +- src/core/protocol/index.ts | 12 +- src/core/protocol/reducer.test.ts | 424 +-- src/core/protocol/reducer.ts | 154 +- src/core/protocol/types.ts | 28 +- src/core/wire/conformance.test.ts | 424 +-- src/core/wire/conformance.ts | 180 +- src/core/wire/index.ts | 8 +- src/features/cache-warming/index.ts | 4 +- .../cache-warming/logic/view-model.test.ts | 406 +-- src/features/cache-warming/logic/view-model.ts | 244 +- .../cache-warming/ui/CacheWarmingView.svelte | 468 +-- src/features/chat/index.ts | 28 +- src/features/chat/model-select.test.ts | 88 +- src/features/chat/model-select.ts | 44 +- src/features/chat/ports.ts | 24 +- src/features/chat/reasoning-effort.test.ts | 68 +- src/features/chat/reasoning-effort.ts | 32 +- src/features/chat/store.svelte.ts | 724 ++--- src/features/chat/store.test.ts | 3060 ++++++++++---------- src/features/chat/test-helpers.ts | 228 +- src/features/chat/ui.test.ts | 1526 +++++----- src/features/chat/ui/ChatView.svelte | 611 ++-- src/features/chat/ui/CompactionView.svelte | 278 +- src/features/chat/ui/Composer.svelte | 358 +-- src/features/chat/ui/ModelSelector.svelte | 82 +- .../chat/ui/ReasoningEffortSelector.svelte | 130 +- src/features/computer/index.ts | 42 +- src/features/computer/logic/view-model.test.ts | 266 +- src/features/computer/logic/view-model.ts | 170 +- src/features/computer/ui/ComputerField.svelte | 410 +-- src/features/computer/ui/ComputerSelect.svelte | 60 +- src/features/conversation-cache/cache.test.ts | 326 +-- src/features/conversation-cache/cache.ts | 98 +- src/features/conversation-cache/index.ts | 10 +- src/features/conversation-cache/logic.test.ts | 248 +- src/features/conversation-cache/logic.ts | 82 +- src/features/conversation-cache/types.ts | 38 +- src/features/cwd-lsp/index.ts | 12 +- src/features/cwd-lsp/logic/view-model.test.ts | 154 +- src/features/cwd-lsp/logic/view-model.ts | 140 +- src/features/cwd-lsp/ui/CwdField.svelte | 170 +- src/features/cwd-lsp/ui/LspStatusView.svelte | 234 +- src/features/markdown/index.ts | 4 +- src/features/markdown/logic/markdown.test.ts | 106 +- src/features/markdown/logic/markdown.ts | 174 +- src/features/markdown/ui/Markdown.svelte | 106 +- src/features/markdown/ui/markdown.test.ts | 52 +- src/features/mcp/index.ts | 4 +- src/features/mcp/logic/view-model.test.ts | 136 +- src/features/mcp/logic/view-model.ts | 128 +- src/features/mcp/ui/McpStatusView.svelte | 242 +- src/features/settings/index.ts | 4 +- src/features/settings/logic/view-model.test.ts | 104 +- src/features/settings/logic/view-model.ts | 32 +- src/features/settings/ui/ChatLimitField.svelte | 186 +- src/features/settings/ui/ChatLimitField.test.ts | 128 +- src/features/smart-scroll/index.ts | 28 +- .../smart-scroll/logic/smart-scroll.test.ts | 156 +- src/features/smart-scroll/logic/smart-scroll.ts | 56 +- src/features/smart-scroll/ui/ScrollToBottom.svelte | 60 +- src/features/smart-scroll/ui/controller.svelte.ts | 242 +- src/features/smart-scroll/ui/controller.test.ts | 296 +- src/features/surface-host/index.ts | 4 +- .../surface-host/logic/message-queue.test.ts | 72 +- src/features/surface-host/logic/message-queue.ts | 38 +- src/features/surface-host/logic/plan.test.ts | 468 +-- src/features/surface-host/logic/plan.ts | 210 +- src/features/surface-host/logic/table.test.ts | 78 +- src/features/surface-host/logic/table.ts | 66 +- src/features/surface-host/logic/todo.test.ts | 110 +- src/features/surface-host/logic/todo.ts | 32 +- src/features/surface-host/logic/types.ts | 78 +- src/features/surface-host/ui/Button.svelte | 30 +- .../surface-host/ui/MessageQueueList.svelte | 30 +- src/features/surface-host/ui/Number.svelte | 72 +- src/features/surface-host/ui/Progress.svelte | 12 +- src/features/surface-host/ui/Selector.svelte | 50 +- src/features/surface-host/ui/StatTable.svelte | 30 +- src/features/surface-host/ui/SurfaceTable.svelte | 14 +- src/features/surface-host/ui/SurfaceView.svelte | 87 +- src/features/surface-host/ui/TodoList.svelte | 106 +- src/features/surface-host/ui/Toggle.svelte | 36 +- src/features/system-prompt/index.ts | 18 +- .../system-prompt/logic/view-model.test.ts | 148 +- src/features/system-prompt/logic/view-model.ts | 74 +- .../system-prompt/ui/SystemPromptBuilder.svelte | 467 +-- src/features/tabs/index.ts | 26 +- src/features/tabs/tabs-store.svelte.ts | 118 +- src/features/tabs/tabs-store.test.ts | 282 +- src/features/tabs/tabs.test.ts | 418 +-- src/features/tabs/tabs.ts | 120 +- src/features/tabs/ui.test.ts | 404 +-- src/features/tabs/ui/TabBar.svelte | 325 ++- src/features/views/index.ts | 16 +- src/features/views/logic/panels.test.ts | 88 +- src/features/views/logic/panels.ts | 34 +- src/features/views/ui/ViewSidebar.svelte | 170 +- src/features/views/ui/ViewSidebar.test.ts | 76 +- src/features/workspaces/adapter/http.test.ts | 224 +- src/features/workspaces/adapter/http.ts | 242 +- src/features/workspaces/index.ts | 14 +- src/features/workspaces/logic/route.test.ts | 112 +- src/features/workspaces/logic/route.ts | 26 +- src/features/workspaces/logic/view-model.test.ts | 98 +- src/features/workspaces/logic/view-model.ts | 32 +- src/features/workspaces/store.svelte.ts | 132 +- src/features/workspaces/ui/WorkspaceCard.svelte | 407 ++- src/features/workspaces/ui/WorkspaceCard.test.ts | 234 +- src/features/workspaces/ui/WorkspacesHome.svelte | 165 +- src/main.ts | 2 +- 154 files changed, 19407 insertions(+), 19345 deletions(-) (limited to 'src') diff --git a/src/App.svelte b/src/App.svelte index 402c88d..0dbc958 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,74 +1,74 @@ {#if route.kind === "home"} - + {:else} - + {/if} diff --git a/src/adapters/history/index.test.ts b/src/adapters/history/index.test.ts index dec2323..faab9d4 100644 --- a/src/adapters/history/index.test.ts +++ b/src/adapters/history/index.test.ts @@ -8,91 +8,91 @@ import { createHistoryAdapter, type HistoryWindow } from "./index"; * popstate listeners. */ function fakeWindow(initial = "/"): HistoryWindow & { back(url: string): void } { - let pathname = initial; - const popstateListeners = new Set<() => void>(); - return { - get location() { - return { - get pathname() { - return pathname; - }, - }; - }, - history: { - pushState(_data, _unused, url) { - if (typeof url === "string") pathname = url; - }, - replaceState(_data, _unused, url) { - if (typeof url === "string") pathname = url; - }, - }, - addEventListener(type, listener) { - if (type === "popstate") popstateListeners.add(listener); - }, - removeEventListener(type, listener) { - if (type === "popstate") popstateListeners.delete(listener); - }, - back(url: string) { - pathname = url; - for (const l of popstateListeners) l(); - }, - }; + let pathname = initial; + const popstateListeners = new Set<() => void>(); + return { + get location() { + return { + get pathname() { + return pathname; + }, + }; + }, + history: { + pushState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + replaceState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + }, + addEventListener(type, listener) { + if (type === "popstate") popstateListeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === "popstate") popstateListeners.delete(listener); + }, + back(url: string) { + pathname = url; + for (const l of popstateListeners) l(); + }, + }; } describe("createHistoryAdapter", () => { - it("reads the current path", () => { - const w = fakeWindow("/my-ws"); - const h = createHistoryAdapter({ window: w }); - expect(h.path).toBe("/my-ws"); - }); + it("reads the current path", () => { + const w = fakeWindow("/my-ws"); + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/my-ws"); + }); - it("navigate updates the path + notifies subscribers", () => { - const w = fakeWindow("/"); - const h = createHistoryAdapter({ window: w }); - const seen: string[] = []; - h.subscribe((p) => seen.push(p)); - h.navigate("/default"); - expect(h.path).toBe("/default"); - expect(seen).toEqual(["/default"]); - }); + it("navigate updates the path + notifies subscribers", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual(["/default"]); + }); - it("replace updates the path WITHOUT notifying", () => { - const w = fakeWindow("/"); - const h = createHistoryAdapter({ window: w }); - const seen: string[] = []; - h.subscribe((p) => seen.push(p)); - h.replace("/default"); - expect(h.path).toBe("/default"); - expect(seen).toEqual([]); - }); + it("replace updates the path WITHOUT notifying", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.replace("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual([]); + }); - it("fires subscribers on popstate (back/forward)", () => { - const w = fakeWindow("/"); - const h = createHistoryAdapter({ window: w }); - const seen: string[] = []; - h.subscribe((p) => seen.push(p)); - w.back("/my-ws"); - expect(seen).toEqual(["/my-ws"]); - }); + it("fires subscribers on popstate (back/forward)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + w.back("/my-ws"); + expect(seen).toEqual(["/my-ws"]); + }); - it("unsubscribe stops notifications (navigate + popstate)", () => { - const w = fakeWindow("/"); - const h = createHistoryAdapter({ window: w }); - const seen: string[] = []; - const unsub = h.subscribe((p) => seen.push(p)); - unsub(); - h.navigate("/default"); - w.back("/other"); - expect(seen).toEqual([]); - }); + it("unsubscribe stops notifications (navigate + popstate)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + const unsub = h.subscribe((p) => seen.push(p)); + unsub(); + h.navigate("/default"); + w.back("/other"); + expect(seen).toEqual([]); + }); - it("degrades to a no-op adapter when there is no location (SSR)", () => { - const w = { location: undefined } as unknown as HistoryWindow; - const h = createHistoryAdapter({ window: w }); - expect(h.path).toBe("/"); - const seen: string[] = []; - h.subscribe((p) => seen.push(p)); - h.navigate("/default"); - expect(seen).toEqual([]); - }); + it("degrades to a no-op adapter when there is no location (SSR)", () => { + const w = { location: undefined } as unknown as HistoryWindow; + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/"); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(seen).toEqual([]); + }); }); diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts index 87deafb..2886053 100644 --- a/src/adapters/history/index.ts +++ b/src/adapters/history/index.ts @@ -15,79 +15,79 @@ /** The minimal browser-history surface this adapter needs. */ export interface HistoryWindow { - readonly location: { readonly pathname: string }; - readonly history: { - pushState(data: unknown, unused: string, url?: string | URL | null): void; - replaceState(data: unknown, unused: string, url?: string | URL | null): void; - }; - addEventListener(type: "popstate", listener: () => void): void; - removeEventListener(type: "popstate", listener: () => void): void; + readonly location: { readonly pathname: string }; + readonly history: { + pushState(data: unknown, unused: string, url?: string | URL | null): void; + replaceState(data: unknown, unused: string, url?: string | URL | null): void; + }; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; } export interface HistoryAdapter { - /** The current pathname. */ - readonly path: string; - /** Push a new path (updates the URL without a reload) + notifies subscribers. */ - navigate(path: string): void; - /** Replace the current path without adding a history entry (no notify). */ - replace(path: string): void; - /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ - subscribe(cb: (path: string) => void): () => void; + /** The current pathname. */ + readonly path: string; + /** Push a new path (updates the URL without a reload) + notifies subscribers. */ + navigate(path: string): void; + /** Replace the current path without adding a history entry (no notify). */ + replace(path: string): void; + /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ + subscribe(cb: (path: string) => void): () => void; } export interface CreateHistoryOptions { - /** The browser window; defaults to `globalThis`. Inject for tests. */ - readonly window?: HistoryWindow; + /** The browser window; defaults to `globalThis`. Inject for tests. */ + readonly window?: HistoryWindow; } function noop(): void {} /** A no-op adapter for when there is no `window` (SSR). */ function createNoopHistory(): HistoryAdapter { - return { - get path() { - return "/"; - }, - navigate: noop, - replace: noop, - subscribe() { - return noop; - }, - }; + return { + get path() { + return "/"; + }, + navigate: noop, + replace: noop, + subscribe() { + return noop; + }, + }; } export function createHistoryAdapter(opts?: CreateHistoryOptions): HistoryAdapter { - const w = opts?.window ?? (globalThis as unknown as HistoryWindow); - if (w === undefined || w === null || w.location === undefined) { - return createNoopHistory(); - } + const w = opts?.window ?? (globalThis as unknown as HistoryWindow); + if (w === undefined || w === null || w.location === undefined) { + return createNoopHistory(); + } - const listeners = new Set<(path: string) => void>(); - const current = (): string => w.location.pathname; - const emit = (): void => { - const p = current(); - for (const cb of listeners) cb(p); - }; + const listeners = new Set<(path: string) => void>(); + const current = (): string => w.location.pathname; + const emit = (): void => { + const p = current(); + for (const cb of listeners) cb(p); + }; - return { - get path() { - return current(); - }, - navigate(path: string): void { - w.history.pushState(null, "", path); - emit(); - }, - replace(path: string): void { - w.history.replaceState(null, "", path); - }, - subscribe(cb: (path: string) => void): () => void { - listeners.add(cb); - const onPop = (): void => cb(current()); - w.addEventListener("popstate", onPop); - return () => { - listeners.delete(cb); - w.removeEventListener("popstate", onPop); - }; - }, - }; + return { + get path() { + return current(); + }, + navigate(path: string): void { + w.history.pushState(null, "", path); + emit(); + }, + replace(path: string): void { + w.history.replaceState(null, "", path); + }, + subscribe(cb: (path: string) => void): () => void { + listeners.add(cb); + const onPop = (): void => cb(current()); + w.addEventListener("popstate", onPop); + return () => { + listeners.delete(cb); + w.removeEventListener("popstate", onPop); + }; + }, + }; } diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts index 12bb5ad..c05c605 100644 --- a/src/adapters/idb/index.test.ts +++ b/src/adapters/idb/index.test.ts @@ -4,117 +4,117 @@ import { describe, expect, it } from "vitest"; import { createIdbChunkStore } from "./index"; function textChunk(text: string): StoredChunk["chunk"] { - return { type: "text", text }; + return { type: "text", text }; } function makeChunk( - seq: number, - text: string, - role: StoredChunk["role"] = "assistant", + seq: number, + text: string, + role: StoredChunk["role"] = "assistant", ): StoredChunk { - return { seq, role, chunk: textChunk(text) }; + return { seq, role, chunk: textChunk(text) }; } describe("createIdbChunkStore", () => { - it("append then load returns chunks seq-ordered", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]; + it("append then load returns chunks seq-ordered", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]; - await store.append("conv1", chunks); - const loaded = await store.load("conv1"); + await store.append("conv1", chunks); + const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded[0]?.seq).toBe(1); - expect(loaded[1]?.seq).toBe(2); - expect(loaded[2]?.seq).toBe(3); - expect(loaded[0]?.chunk).toEqual(textChunk("a")); - }); + expect(loaded).toHaveLength(3); + expect(loaded[0]?.seq).toBe(1); + expect(loaded[1]?.seq).toBe(2); + expect(loaded[2]?.seq).toBe(3); + expect(loaded[0]?.chunk).toEqual(textChunk("a")); + }); - it("append out-of-order still loads seq-ordered", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; + it("append out-of-order still loads seq-ordered", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; - await store.append("conv1", chunks); - const loaded = await store.load("conv1"); + await store.append("conv1", chunks); + const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); - }); + expect(loaded).toHaveLength(3); + expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); + }); - it("append is idempotent on duplicate seq", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("append is idempotent on duplicate seq", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]); - await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]); + await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]); + await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]); - const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); - expect(loaded[0]?.chunk).toEqual(textChunk("first")); - }); + const loaded = await store.load("conv1"); + expect(loaded).toHaveLength(3); + expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); + expect(loaded[0]?.chunk).toEqual(textChunk("first")); + }); - it("load returns [] for an absent conversation", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("load returns [] for an absent conversation", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const loaded = await store.load("nonexistent"); - expect(loaded).toEqual([]); - }); + const loaded = await store.load("nonexistent"); + expect(loaded).toEqual([]); + }); - it("delete removes a conversation", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("delete removes a conversation", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a")]); - await store.append("conv2", [makeChunk(1, "b")]); + await store.append("conv1", [makeChunk(1, "a")]); + await store.append("conv2", [makeChunk(1, "b")]); - await store.delete("conv1"); + await store.delete("conv1"); - expect(await store.load("conv1")).toEqual([]); - const conv2 = await store.load("conv2"); - expect(conv2).toHaveLength(1); - expect(conv2[0]?.chunk).toEqual(textChunk("b")); - }); + expect(await store.load("conv1")).toEqual([]); + const conv2 = await store.load("conv2"); + expect(conv2).toHaveLength(1); + expect(conv2[0]?.chunk).toEqual(textChunk("b")); + }); - it("index aggregates chunkCount and maxSeq", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("index aggregates chunkCount and maxSeq", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]); - await store.append("conv2", [makeChunk(1, "x")]); + await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]); + await store.append("conv2", [makeChunk(1, "x")]); - const idx = await store.index(); - expect(idx).toHaveLength(2); + const idx = await store.index(); + expect(idx).toHaveLength(2); - const c1 = idx.find((e) => e.conversationId === "conv1"); - const c2 = idx.find((e) => e.conversationId === "conv2"); + const c1 = idx.find((e) => e.conversationId === "conv1"); + const c2 = idx.find((e) => e.conversationId === "conv2"); - expect(c1?.chunkCount).toBe(3); - expect(c1?.maxSeq).toBe(3); - expect(c2?.chunkCount).toBe(1); - expect(c2?.maxSeq).toBe(1); - }); + expect(c1?.chunkCount).toBe(3); + expect(c1?.maxSeq).toBe(3); + expect(c2?.chunkCount).toBe(1); + expect(c2?.maxSeq).toBe(1); + }); - it("index reports lastAccess after load", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("index reports lastAccess after load", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a")]); - const idx = await store.index(); + await store.append("conv1", [makeChunk(1, "a")]); + const idx = await store.index(); - const entry = idx.find((e) => e.conversationId === "conv1"); - expect(entry?.lastAccess).toBeTypeOf("number"); - expect(entry?.lastAccess).toBeGreaterThan(0); - }); + const entry = idx.find((e) => e.conversationId === "conv1"); + expect(entry?.lastAccess).toBeTypeOf("number"); + expect(entry?.lastAccess).toBeGreaterThan(0); + }); - it("separate conversations are isolated", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("separate conversations are isolated", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]); - await store.append("conv2", [makeChunk(1, "b1")]); + await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]); + await store.append("conv2", [makeChunk(1, "b1")]); - const loaded1 = await store.load("conv1"); - const loaded2 = await store.load("conv2"); + const loaded1 = await store.load("conv1"); + const loaded2 = await store.load("conv2"); - expect(loaded1).toHaveLength(2); - expect(loaded2).toHaveLength(1); - expect(loaded1[0]?.chunk).toEqual(textChunk("a1")); - expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); - }); + expect(loaded1).toHaveLength(2); + expect(loaded2).toHaveLength(1); + expect(loaded1[0]?.chunk).toEqual(textChunk("a1")); + expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); + }); }); diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts index 302edb5..96b2cbc 100644 --- a/src/adapters/idb/index.ts +++ b/src/adapters/idb/index.ts @@ -1,7 +1,7 @@ import type { StoredChunk } from "@dispatch/wire"; import type { - ConversationCacheIndexEntry, - ConversationChunkStore, + ConversationCacheIndexEntry, + ConversationChunkStore, } from "../../features/conversation-cache"; const DEFAULT_DB_NAME = "dispatch-chunk-cache"; @@ -10,172 +10,172 @@ const CHUNKS_STORE = "chunks"; const META_STORE = "meta"; interface ChunkRecord { - conversationId: string; - seq: number; - role: StoredChunk["role"]; - chunk: StoredChunk["chunk"]; + conversationId: string; + seq: number; + role: StoredChunk["role"]; + chunk: StoredChunk["chunk"]; } interface MetaRecord { - conversationId: string; - lastAccess: number; + conversationId: string; + lastAccess: number; } export interface CreateIdbChunkStoreOptions { - indexedDB?: IDBFactory; - dbName?: string; + indexedDB?: IDBFactory; + dbName?: string; } function requestToPromise(req: IDBRequest): Promise { - return new Promise((resolve, reject) => { - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } function txComplete(tx: IDBTransaction): Promise { - return new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); } function openDb(idb: IDBFactory, dbName: string): Promise { - return new Promise((resolve, reject) => { - const req = idb.open(dbName, DB_VERSION); - - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(CHUNKS_STORE)) { - const store = db.createObjectStore(CHUNKS_STORE, { - keyPath: ["conversationId", "seq"], - }); - store.createIndex("byConversation", "conversationId"); - } - if (!db.objectStoreNames.contains(META_STORE)) { - db.createObjectStore(META_STORE, { keyPath: "conversationId" }); - } - }; - - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise((resolve, reject) => { + const req = idb.open(dbName, DB_VERSION); + + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(CHUNKS_STORE)) { + const store = db.createObjectStore(CHUNKS_STORE, { + keyPath: ["conversationId", "seq"], + }); + store.createIndex("byConversation", "conversationId"); + } + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: "conversationId" }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } function keyRangeFor(conversationId: string): IDBKeyRange { - const lower: [string, number] = [conversationId, 0]; - const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY]; - return IDBKeyRange.bound(lower, upper); + const lower: [string, number] = [conversationId, 0]; + const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY]; + return IDBKeyRange.bound(lower, upper); } function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] { - return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); + return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); } export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore { - const idb = opts?.indexedDB ?? globalThis.indexedDB; - const dbName = opts?.dbName ?? DEFAULT_DB_NAME; - - let dbPromise: Promise | null = null; - - function getDb(): Promise { - if (dbPromise === null) { - dbPromise = openDb(idb, dbName); - } - return dbPromise; - } - - return { - async load(conversationId: string): Promise { - const db = await getDb(); - const tx = db.transaction(CHUNKS_STORE, "readonly"); - const store = tx.objectStore(CHUNKS_STORE); - const range = keyRangeFor(conversationId); - const records = await requestToPromise(store.getAll(range)); - await txComplete(tx); - - records.sort((a, b) => a.seq - b.seq); - return chunksToStoredChunks(records); - }, - - async append(conversationId: string, chunks: readonly StoredChunk[]): Promise { - if (chunks.length === 0) return; - - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - for (const c of chunks) { - chunkStore.put({ - conversationId, - seq: c.seq, - role: c.role, - chunk: c.chunk, - } satisfies ChunkRecord); - } - - metaStore.put({ - conversationId, - lastAccess: Date.now(), - } satisfies MetaRecord); - - await txComplete(tx); - }, - - async delete(conversationId: string): Promise { - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - chunkStore.delete(keyRangeFor(conversationId)); - metaStore.delete(conversationId); - - await txComplete(tx); - }, - - async index(): Promise { - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - const allChunks = await requestToPromise(chunkStore.getAll()); - const allMeta = await requestToPromise(metaStore.getAll()); - await txComplete(tx); - - const metaMap = new Map(); - for (const m of allMeta) { - metaMap.set(m.conversationId, m.lastAccess); - } - - const grouped = new Map(); - for (const r of allChunks) { - const existing = grouped.get(r.conversationId); - if (existing === undefined) { - grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq }); - } else { - existing.chunkCount++; - if (r.seq > existing.maxSeq) { - existing.maxSeq = r.seq; - } - } - } - - const result: ConversationCacheIndexEntry[] = []; - for (const [conversationId, stats] of grouped) { - const lastAccess = metaMap.get(conversationId); - result.push({ - conversationId, - chunkCount: stats.chunkCount, - maxSeq: stats.maxSeq, - ...(lastAccess !== undefined ? { lastAccess } : {}), - }); - } - - return result; - }, - }; + const idb = opts?.indexedDB ?? globalThis.indexedDB; + const dbName = opts?.dbName ?? DEFAULT_DB_NAME; + + let dbPromise: Promise | null = null; + + function getDb(): Promise { + if (dbPromise === null) { + dbPromise = openDb(idb, dbName); + } + return dbPromise; + } + + return { + async load(conversationId: string): Promise { + const db = await getDb(); + const tx = db.transaction(CHUNKS_STORE, "readonly"); + const store = tx.objectStore(CHUNKS_STORE); + const range = keyRangeFor(conversationId); + const records = await requestToPromise(store.getAll(range)); + await txComplete(tx); + + records.sort((a, b) => a.seq - b.seq); + return chunksToStoredChunks(records); + }, + + async append(conversationId: string, chunks: readonly StoredChunk[]): Promise { + if (chunks.length === 0) return; + + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + for (const c of chunks) { + chunkStore.put({ + conversationId, + seq: c.seq, + role: c.role, + chunk: c.chunk, + } satisfies ChunkRecord); + } + + metaStore.put({ + conversationId, + lastAccess: Date.now(), + } satisfies MetaRecord); + + await txComplete(tx); + }, + + async delete(conversationId: string): Promise { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + chunkStore.delete(keyRangeFor(conversationId)); + metaStore.delete(conversationId); + + await txComplete(tx); + }, + + async index(): Promise { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + const allChunks = await requestToPromise(chunkStore.getAll()); + const allMeta = await requestToPromise(metaStore.getAll()); + await txComplete(tx); + + const metaMap = new Map(); + for (const m of allMeta) { + metaMap.set(m.conversationId, m.lastAccess); + } + + const grouped = new Map(); + for (const r of allChunks) { + const existing = grouped.get(r.conversationId); + if (existing === undefined) { + grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq }); + } else { + existing.chunkCount++; + if (r.seq > existing.maxSeq) { + existing.maxSeq = r.seq; + } + } + } + + const result: ConversationCacheIndexEntry[] = []; + for (const [conversationId, stats] of grouped) { + const lastAccess = metaMap.get(conversationId); + result.push({ + conversationId, + chunkCount: stats.chunkCount, + maxSeq: stats.maxSeq, + ...(lastAccess !== undefined ? { lastAccess } : {}), + }); + } + + return result; + }, + }; } diff --git a/src/adapters/local-storage/index.test.ts b/src/adapters/local-storage/index.test.ts index 57103dd..3370dd7 100644 --- a/src/adapters/local-storage/index.test.ts +++ b/src/adapters/local-storage/index.test.ts @@ -2,119 +2,119 @@ import { describe, expect, it } from "vitest"; import { createLocalStore } from "./index"; function createMemoryStorage(): Storage { - const map = new Map(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string) { - return map.get(key) ?? null; - }, - key(index: number) { - return [...map.keys()][index] ?? null; - }, - removeItem(key: string) { - map.delete(key); - }, - setItem(key: string, value: string) { - map.set(key, value); - }, - }; + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string) { + return map.get(key) ?? null; + }, + key(index: number) { + return [...map.keys()][index] ?? null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } describe("createLocalStore", () => { - it("save then load round-trips an object", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<{ name: string; count: number }>("test", { storage }); - - store.save({ name: "alice", count: 42 }); - const loaded = store.load(); - - expect(loaded).toEqual({ name: "alice", count: 42 }); - }); - - it("load returns null when key is absent", () => { - const storage = createMemoryStorage(); - const store = createLocalStore("missing", { storage }); - - expect(store.load()).toBeNull(); - }); - - it("load returns null on corrupt JSON", () => { - const storage = createMemoryStorage(); - storage.setItem("corrupt", "{not valid json!!!"); - const store = createLocalStore("corrupt", { storage }); - - expect(store.load()).toBeNull(); - }); - - it("clear removes the value", () => { - const storage = createMemoryStorage(); - const store = createLocalStore("key", { storage }); - - store.save("hello"); - expect(store.load()).toBe("hello"); - - store.clear(); - expect(store.load()).toBeNull(); - }); - - it("save swallows a throwing setItem (quota) without throwing", () => { - const storage = createMemoryStorage(); - const originalSetItem = storage.setItem.bind(storage); - let callCount = 0; - storage.setItem = (_key: string, _value: string) => { - callCount++; - if (callCount > 1) { - throw new DOMException("QuotaExceededError", "QuotaExceededError"); - } - originalSetItem(_key, _value); - }; - - const store = createLocalStore("quota", { storage }); - - // First save works - store.save([1, 2, 3]); - expect(store.load()).toEqual([1, 2, 3]); - - // Second save throws but is swallowed - expect(() => store.save([4, 5, 6])).not.toThrow(); - }); - - it("construction with undefined storage yields a safe no-op store", () => { - const store = createLocalStore("noop", { storage: undefined }); - - // All operations are safe no-ops - expect(store.load()).toBeNull(); - expect(() => store.save("hello")).not.toThrow(); - expect(() => store.clear()).not.toThrow(); - }); - - it("round-trips arrays", () => { - const storage = createMemoryStorage(); - const store = createLocalStore("arr", { storage }); - - store.save([1, 2, 3]); - expect(store.load()).toEqual([1, 2, 3]); - }); - - it("round-trips nested objects", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); - - store.save({ a: { b: ["x", "y"] } }); - expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); - }); - - it("overwrites previous value on repeated save", () => { - const storage = createMemoryStorage(); - const store = createLocalStore("key", { storage }); - - store.save("first"); - store.save("second"); - expect(store.load()).toBe("second"); - }); + it("save then load round-trips an object", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ name: string; count: number }>("test", { storage }); + + store.save({ name: "alice", count: 42 }); + const loaded = store.load(); + + expect(loaded).toEqual({ name: "alice", count: 42 }); + }); + + it("load returns null when key is absent", () => { + const storage = createMemoryStorage(); + const store = createLocalStore("missing", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("load returns null on corrupt JSON", () => { + const storage = createMemoryStorage(); + storage.setItem("corrupt", "{not valid json!!!"); + const store = createLocalStore("corrupt", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("clear removes the value", () => { + const storage = createMemoryStorage(); + const store = createLocalStore("key", { storage }); + + store.save("hello"); + expect(store.load()).toBe("hello"); + + store.clear(); + expect(store.load()).toBeNull(); + }); + + it("save swallows a throwing setItem (quota) without throwing", () => { + const storage = createMemoryStorage(); + const originalSetItem = storage.setItem.bind(storage); + let callCount = 0; + storage.setItem = (_key: string, _value: string) => { + callCount++; + if (callCount > 1) { + throw new DOMException("QuotaExceededError", "QuotaExceededError"); + } + originalSetItem(_key, _value); + }; + + const store = createLocalStore("quota", { storage }); + + // First save works + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + + // Second save throws but is swallowed + expect(() => store.save([4, 5, 6])).not.toThrow(); + }); + + it("construction with undefined storage yields a safe no-op store", () => { + const store = createLocalStore("noop", { storage: undefined }); + + // All operations are safe no-ops + expect(store.load()).toBeNull(); + expect(() => store.save("hello")).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + }); + + it("round-trips arrays", () => { + const storage = createMemoryStorage(); + const store = createLocalStore("arr", { storage }); + + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + }); + + it("round-trips nested objects", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); + + store.save({ a: { b: ["x", "y"] } }); + expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); + }); + + it("overwrites previous value on repeated save", () => { + const storage = createMemoryStorage(); + const store = createLocalStore("key", { storage }); + + store.save("first"); + store.save("second"); + expect(store.load()).toBe("second"); + }); }); diff --git a/src/adapters/local-storage/index.ts b/src/adapters/local-storage/index.ts index 72135ce..9dd2ffd 100644 --- a/src/adapters/local-storage/index.ts +++ b/src/adapters/local-storage/index.ts @@ -1,58 +1,58 @@ export interface LocalStore { - load(): T | null; - save(value: T): void; - clear(): void; + load(): T | null; + save(value: T): void; + clear(): void; } export interface CreateLocalStoreOptions { - storage?: Storage | undefined; + storage?: Storage | undefined; } function createNoopStore(): LocalStore { - return { - load() { - return null; - }, - save() {}, - clear() {}, - }; + return { + load() { + return null; + }, + save() {}, + clear() {}, + }; } export function createLocalStore(key: string, opts?: CreateLocalStoreOptions): LocalStore { - let storage: Storage | undefined; - if (opts !== undefined && "storage" in opts) { - storage = opts.storage; - } else { - storage = globalThis.localStorage; - } + let storage: Storage | undefined; + if (opts !== undefined && "storage" in opts) { + storage = opts.storage; + } else { + storage = globalThis.localStorage; + } - if (storage === undefined || storage === null) { - return createNoopStore(); - } + if (storage === undefined || storage === null) { + return createNoopStore(); + } - return { - load(): T | null { - try { - const raw = storage.getItem(key); - if (raw === null) { - return null; - } - return JSON.parse(raw) as T; - } catch { - return null; - } - }, + return { + load(): T | null { + try { + const raw = storage.getItem(key); + if (raw === null) { + return null; + } + return JSON.parse(raw) as T; + } catch { + return null; + } + }, - save(value: T): void { - try { - storage.setItem(key, JSON.stringify(value)); - } catch { - // Swallow quota / write errors — persistence is best-effort. - } - }, + save(value: T): void { + try { + storage.setItem(key, JSON.stringify(value)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + }, - clear(): void { - storage.removeItem(key); - }, - }; + clear(): void { + storage.removeItem(key); + }, + }; } diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index e8e5434..9d821e2 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -3,405 +3,405 @@ import type { WebSocketLike } from "./index"; import { createSurfaceSocket } from "./index"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - invokeMessage(data: string): void; - invokeClose(): void; + sent: string[]; + resolveOpen(): void; + invokeMessage(data: string): void; + invokeClose(): void; } function fakeSocket(): FakeSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - let onclose: ((ev: { code: number; reason: string }) => void) | null = null; - const sent: string[] = []; - - const ws: FakeSocket = { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return onclose; - }, - set onclose(fn) { - onclose = fn; - }, - resolveOpen() { - onopen?.(); - }, - invokeMessage(data: string) { - onmessage?.({ data }); - }, - invokeClose() { - onclose?.({ code: 1000, reason: "" }); - }, - sent, - }; - return ws; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + let onclose: ((ev: { code: number; reason: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return onclose; + }, + set onclose(fn) { + onclose = fn; + }, + resolveOpen() { + onopen?.(); + }, + invokeMessage(data: string) { + onmessage?.({ data }); + }, + invokeClose() { + onclose?.({ code: 1000, reason: "" }); + }, + sent, + }; + return ws; } describe("createSurfaceSocket", () => { - it("sends queued messages once socket opens", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - handle.send({ type: "subscribe", surfaceId: "s1" }); - handle.send({ type: "subscribe", surfaceId: "s2" }); - expect(ws.sent).toHaveLength(0); - - ws.resolveOpen(); - expect(ws.sent).toHaveLength(2); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); - expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); - }); - - it("sends immediately when socket is already open", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.sent.length = 0; - - handle.send({ type: "subscribe", surfaceId: "s1" }); - expect(ws.sent).toHaveLength(1); - }); - - it("routes inbound messages to onMessage via parseServerMessage", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); - expect(onMessage).toHaveBeenCalledOnce(); - expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); - }); - - it("drops malformed inbound messages silently", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage("not json"); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("auto-reconnects on close and fires onReopen after successful reconnect", () => { - vi.useFakeTimers(); - try { - const sockets: ReturnType[] = []; - const onMessage = vi.fn(); - const onReopen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onReopen, - socketFactory: () => { - const ws = fakeSocket(); - sockets.push(ws); - return ws; - }, - }); - - expect(sockets).toHaveLength(1); - sockets[0]?.resolveOpen(); - - // Simulate close - sockets[0]?.invokeClose(); - - // Fast-forward past the backoff delay - vi.advanceTimersByTime(600); - - expect(sockets).toHaveLength(2); - // onReopen should NOT have fired yet (socket not open) - expect(onReopen).not.toHaveBeenCalled(); - - sockets[1]?.resolveOpen(); - expect(onReopen).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); - - it("does not fire onReopen on initial connect", () => { - const ws = fakeSocket(); - const onReopen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - onReopen, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - expect(onReopen).not.toHaveBeenCalled(); - }); - - it("close() prevents further reconnects", () => { - vi.useFakeTimers(); - try { - const sockets: ReturnType[] = []; - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => { - const ws = fakeSocket(); - sockets.push(ws); - return ws; - }, - }); - - sockets[0]?.resolveOpen(); - sockets[0]?.invokeClose(); - handle.close(); - - vi.advanceTimersByTime(10_000); - expect(sockets).toHaveLength(1); - } finally { - vi.useRealTimers(); - } - }); - - it("close() prevents further sends", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.sent.length = 0; - handle.close(); - - handle.send({ type: "subscribe", surfaceId: "s1" }); - expect(ws.sent).toHaveLength(0); - }); - - it("queues multiple sends before open and flushes in order", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - handle.send({ type: "subscribe", surfaceId: "a" }); - handle.send({ type: "subscribe", surfaceId: "b" }); - handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); - ws.resolveOpen(); - - expect(ws.sent).toHaveLength(3); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); - expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); - expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ - type: "invoke", - surfaceId: "a", - actionId: "x", - payload: 1, - }); - }); - - it("routes chat.delta to onChat", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; - ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); - expect(onChat).toHaveBeenCalledOnce(); - expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("routes chat.error to onChat", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); - expect(onChat).toHaveBeenCalledOnce(); - expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("routes conversation.open to onConversationOpen", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - const onConversationOpen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - onConversationOpen, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage( - JSON.stringify({ - type: "conversation.open", - conversationId: "c1", - workspaceId: "w1", - }), - ); - expect(onConversationOpen).toHaveBeenCalledOnce(); - expect(onConversationOpen).toHaveBeenCalledWith({ - type: "conversation.open", - conversationId: "c1", - workspaceId: "w1", - }); - expect(onMessage).not.toHaveBeenCalled(); - expect(onChat).not.toHaveBeenCalled(); - }); - - it("routes conversation.statusChanged to onConversationStatusChanged", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onConversationStatusChanged = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onConversationStatusChanged, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage( - JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - workspaceId: "w1", - }), - ); - expect(onConversationStatusChanged).toHaveBeenCalledOnce(); - expect(onConversationStatusChanged).toHaveBeenCalledWith({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - workspaceId: "w1", - }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("still routes surface catalog/surface to onMessage", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); - expect(onMessage).toHaveBeenCalledOnce(); - expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); - expect(onChat).not.toHaveBeenCalled(); - - ws.invokeMessage( - JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), - ); - expect(onMessage).toHaveBeenCalledTimes(2); - }); - - it("send accepts and serializes a chat.send message", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - handle.send({ type: "chat.send", message: "hello" }); - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); - }); - - it("onChat absent is safe (surface-only usage does not throw)", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - expect(() => { - ws.invokeMessage( - JSON.stringify({ - type: "chat.delta", - event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, - }), - ); - ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); - }).not.toThrow(); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("chat send is queued until open then flushed", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - handle.send({ type: "chat.send", message: "queued" }); - expect(ws.sent).toHaveLength(0); - - ws.resolveOpen(); - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); - }); + it("sends queued messages once socket opens", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + handle.send({ type: "subscribe", surfaceId: "s2" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(2); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); + }); + + it("sends immediately when socket is already open", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(1); + }); + + it("routes inbound messages to onMessage via parseServerMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + }); + + it("drops malformed inbound messages silently", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage("not json"); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("auto-reconnects on close and fires onReopen after successful reconnect", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType[] = []; + const onMessage = vi.fn(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onReopen, + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + expect(sockets).toHaveLength(1); + sockets[0]?.resolveOpen(); + + // Simulate close + sockets[0]?.invokeClose(); + + // Fast-forward past the backoff delay + vi.advanceTimersByTime(600); + + expect(sockets).toHaveLength(2); + // onReopen should NOT have fired yet (socket not open) + expect(onReopen).not.toHaveBeenCalled(); + + sockets[1]?.resolveOpen(); + expect(onReopen).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not fire onReopen on initial connect", () => { + const ws = fakeSocket(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + onReopen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(onReopen).not.toHaveBeenCalled(); + }); + + it("close() prevents further reconnects", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType[] = []; + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + sockets[0]?.resolveOpen(); + sockets[0]?.invokeClose(); + handle.close(); + + vi.advanceTimersByTime(10_000); + expect(sockets).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("close() prevents further sends", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + handle.close(); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(0); + }); + + it("queues multiple sends before open and flushes in order", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "a" }); + handle.send({ type: "subscribe", surfaceId: "b" }); + handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); + ws.resolveOpen(); + + expect(ws.sent).toHaveLength(3); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); + expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ + type: "invoke", + surfaceId: "a", + actionId: "x", + payload: 1, + }); + }); + + it("routes chat.delta to onChat", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; + ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); + expect(onChat).toHaveBeenCalledOnce(); + expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("routes chat.error to onChat", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); + expect(onChat).toHaveBeenCalledOnce(); + expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("routes conversation.open to onConversationOpen", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + const onConversationOpen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + onConversationOpen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }), + ); + expect(onConversationOpen).toHaveBeenCalledOnce(); + expect(onConversationOpen).toHaveBeenCalledWith({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + expect(onMessage).not.toHaveBeenCalled(); + expect(onChat).not.toHaveBeenCalled(); + }); + + it("routes conversation.statusChanged to onConversationStatusChanged", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onConversationStatusChanged = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onConversationStatusChanged, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }), + ); + expect(onConversationStatusChanged).toHaveBeenCalledOnce(); + expect(onConversationStatusChanged).toHaveBeenCalledWith({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("still routes surface catalog/surface to onMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + expect(onChat).not.toHaveBeenCalled(); + + ws.invokeMessage( + JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), + ); + expect(onMessage).toHaveBeenCalledTimes(2); + }); + + it("send accepts and serializes a chat.send message", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + handle.send({ type: "chat.send", message: "hello" }); + expect(ws.sent).toHaveLength(1); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); + }); + + it("onChat absent is safe (surface-only usage does not throw)", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(() => { + ws.invokeMessage( + JSON.stringify({ + type: "chat.delta", + event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, + }), + ); + ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); + }).not.toThrow(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("chat send is queued until open then flushed", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "chat.send", message: "queued" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(1); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); + }); }); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index d2bc13d..1309db0 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,123 +1,123 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - ConversationCompactedMessage, - ConversationOpenMessage, - ConversationStatusChangedMessage, - WsClientMessage, + ChatDeltaMessage, + ChatErrorMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; export interface WebSocketLike { - send(data: string): void; - close(): void; - onopen: (() => void) | null; - onmessage: ((ev: { data: string }) => void) | null; - onclose: ((ev: { code: number; reason: string }) => void) | null; + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((ev: { data: string }) => void) | null; + onclose: ((ev: { code: number; reason: string }) => void) | null; } export interface SurfaceSocketOptions { - url: string; - onMessage: (msg: SurfaceServerMessage) => void; - onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; - /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ - onConversationOpen?: (msg: ConversationOpenMessage) => void; - /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */ - onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; - /** Broadcast when a conversation's history has been compacted (reload needed). */ - onConversationCompacted?: (msg: ConversationCompactedMessage) => void; - onReopen?: () => void; - socketFactory?: (url: string) => WebSocketLike; + url: string; + onMessage: (msg: SurfaceServerMessage) => void; + onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; + /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ + onConversationOpen?: (msg: ConversationOpenMessage) => void; + /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */ + onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; + /** Broadcast when a conversation's history has been compacted (reload needed). */ + onConversationCompacted?: (msg: ConversationCompactedMessage) => void; + onReopen?: () => void; + socketFactory?: (url: string) => WebSocketLike; } export interface SurfaceSocketHandle { - send(msg: WsClientMessage): void; - close(): void; + send(msg: WsClientMessage): void; + close(): void; } export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHandle { - const factory = - opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); + const factory = + opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); - let socket: WebSocketLike | null = null; - let disposed = false; - let reconnectAttempt = 0; - let reconnectTimer: ReturnType | null = null; - let isOpen = false; - const queue: string[] = []; + let socket: WebSocketLike | null = null; + let disposed = false; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType | null = null; + let isOpen = false; + const queue: string[] = []; - function connect(isReconnect: boolean): void { - socket = factory(opts.url); - isOpen = false; + function connect(isReconnect: boolean): void { + socket = factory(opts.url); + isOpen = false; - socket.onopen = () => { - if (disposed) return; - isOpen = true; - reconnectAttempt = 0; - for (const raw of queue.splice(0)) { - socket?.send(raw); - } - if (isReconnect) { - opts.onReopen?.(); - } - }; + socket.onopen = () => { + if (disposed) return; + isOpen = true; + reconnectAttempt = 0; + for (const raw of queue.splice(0)) { + socket?.send(raw); + } + if (isReconnect) { + opts.onReopen?.(); + } + }; - socket.onmessage = (ev) => { - if (disposed) return; - const msg = parseServerMessage(ev.data); - if (msg !== null) { - if (msg.type === "chat.delta" || msg.type === "chat.error") { - opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); - } else if (msg.type === "conversation.open") { - opts.onConversationOpen?.(msg as ConversationOpenMessage); - } else if (msg.type === "conversation.statusChanged") { - opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); - } else if (msg.type === "conversation.compacted") { - opts.onConversationCompacted?.(msg as ConversationCompactedMessage); - } else { - opts.onMessage(msg as SurfaceServerMessage); - } - } - }; + socket.onmessage = (ev) => { + if (disposed) return; + const msg = parseServerMessage(ev.data); + if (msg !== null) { + if (msg.type === "chat.delta" || msg.type === "chat.error") { + opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); + } else if (msg.type === "conversation.open") { + opts.onConversationOpen?.(msg as ConversationOpenMessage); + } else if (msg.type === "conversation.statusChanged") { + opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); + } else if (msg.type === "conversation.compacted") { + opts.onConversationCompacted?.(msg as ConversationCompactedMessage); + } else { + opts.onMessage(msg as SurfaceServerMessage); + } + } + }; - socket.onclose = () => { - if (disposed) return; - isOpen = false; - scheduleReconnect(); - }; - } + socket.onclose = () => { + if (disposed) return; + isOpen = false; + scheduleReconnect(); + }; + } - function scheduleReconnect(): void { - const delay = nextBackoffMs(reconnectAttempt); - reconnectAttempt++; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - if (disposed) return; - connect(true); - }, delay); - } + function scheduleReconnect(): void { + const delay = nextBackoffMs(reconnectAttempt); + reconnectAttempt++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (disposed) return; + connect(true); + }, delay); + } - connect(false); + connect(false); - return { - send(msg: WsClientMessage): void { - if (disposed) return; - const raw = serialize(msg); - if (isOpen) { - socket?.send(raw); - } else { - queue.push(raw); - } - }, - close(): void { - disposed = true; - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - socket?.close(); - socket = null; - }, - }; + return { + send(msg: WsClientMessage): void { + if (disposed) return; + const raw = serialize(msg); + if (isOpen) { + socket?.send(raw); + } else { + queue.push(raw); + } + }, + close(): void { + disposed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket?.close(); + socket = null; + }, + }; } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 062d47b..113a731 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -2,384 +2,384 @@ import { describe, expect, it } from "vitest"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; describe("serialize", () => { - it("serializes a subscribe message", () => { - const msg = { type: "subscribe" as const, surfaceId: "s1" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an unsubscribe message", () => { - const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an invoke message with payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an invoke message without payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes a chat.send message", () => { - const msg = { type: "chat.send" as const, message: "hello" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes a chat.send message with all fields", () => { - const msg = { - type: "chat.send" as const, - conversationId: "c1", - message: "hello", - model: "openai/gpt-4", - cwd: "/tmp", - }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); + it("serializes a subscribe message", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an unsubscribe message", () => { + const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message without payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes a chat.send message", () => { + const msg = { type: "chat.send" as const, message: "hello" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes a chat.send message with all fields", () => { + const msg = { + type: "chat.send" as const, + conversationId: "c1", + message: "hello", + model: "openai/gpt-4", + cwd: "/tmp", + }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); }); describe("parseServerMessage", () => { - it("parses a catalog message", () => { - const data = JSON.stringify({ - type: "catalog", - catalog: [{ id: "s1", region: "r", title: "S1" }], - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "catalog", - catalog: [{ id: "s1", region: "r", title: "S1" }], - }); - }); - - it("parses a surface message", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }); - }); - - it("preserves the conversationId echo on a scoped surface message", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - conversationId: "c1", - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - conversationId: "c1", - }); - }); - - it("rejects a surface message with a non-string conversationId", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - conversationId: 42, - }); - expect(parseServerMessage(data)).toBeNull(); - }); - - it("parses an update message", () => { - const data = JSON.stringify({ - type: "update", - update: { - surfaceId: "s1", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }, - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "update", - update: { - surfaceId: "s1", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }, - }); - }); - - it("parses an error message with surfaceId", () => { - const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); - }); - - it("parses an error message without surfaceId", () => { - const data = JSON.stringify({ type: "error", message: "global boom" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "error", message: "global boom" }); - }); - - it("returns null for malformed JSON", () => { - expect(parseServerMessage("not json")).toBeNull(); - expect(parseServerMessage("{broken")).toBeNull(); - expect(parseServerMessage("")).toBeNull(); - }); - - it("returns null for non-object JSON", () => { - expect(parseServerMessage("42")).toBeNull(); - expect(parseServerMessage('"hello"')).toBeNull(); - expect(parseServerMessage("null")).toBeNull(); - expect(parseServerMessage("true")).toBeNull(); - expect(parseServerMessage("[1,2,3]")).toBeNull(); - }); - - it("returns null for unknown type", () => { - expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); - }); - - it("returns null when type is missing", () => { - expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); - }); - - it("returns null when type is not a string", () => { - expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); - }); - - it("returns null for catalog with non-array catalog field", () => { - expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); - }); - - it("returns null for surface with missing spec fields", () => { - expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); - }); - - it("returns null for surface with non-object spec", () => { - expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); - }); - - it("returns null for update with missing update field", () => { - expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); - }); - - it("returns null for update with invalid spec", () => { - expect( - parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), - ).toBeNull(); - }); - - it("returns null for error with non-string message", () => { - expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); - }); - - it("returns null for error with invalid surfaceId type", () => { - expect( - parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), - ).toBeNull(); - }); - - it("parses a chat.delta message", () => { - const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" }; - const data = JSON.stringify({ type: "chat.delta", event }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.delta", event }); - }); - - it("parses a chat.error message with conversationId", () => { - const data = JSON.stringify({ - type: "chat.error", - conversationId: "c1", - message: "bad request", - }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" }); - }); - - it("parses a chat.error message without conversationId", () => { - const data = JSON.stringify({ type: "chat.error", message: "no conversation" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.error", message: "no conversation" }); - }); - - it("returns null for chat.delta with non-object event", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull(); - }); - - it("returns null for chat.delta with missing event.type", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull(); - }); - - it("returns null for chat.error with non-string message", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull(); - }); - - it("returns null for chat.error with invalid conversationId type", () => { - expect( - parseServerMessage( - JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }), - ), - ).toBeNull(); - }); - - it("parses a conversation.open message", () => { - const data = JSON.stringify({ - type: "conversation.open", - conversationId: "c1", - workspaceId: "w1", - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "conversation.open", - conversationId: "c1", - workspaceId: "w1", - }); - }); - - it("returns null for conversation.open with missing conversationId", () => { - expect( - parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), - ).toBeNull(); - }); - - it("returns null for conversation.open with non-string conversationId", () => { - expect( - parseServerMessage( - JSON.stringify({ - type: "conversation.open", - conversationId: 42, - workspaceId: "w1", - }), - ), - ).toBeNull(); - }); - - it("returns null for conversation.open with missing workspaceId", () => { - expect( - parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })), - ).toBeNull(); - }); - - it("returns null for conversation.open with non-string workspaceId", () => { - expect( - parseServerMessage( - JSON.stringify({ - type: "conversation.open", - conversationId: "c1", - workspaceId: 42, - }), - ), - ).toBeNull(); - }); - - it("parses a conversation.statusChanged message", () => { - const data = JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - workspaceId: "w1", - }); - expect(parseServerMessage(data)).toEqual({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - workspaceId: "w1", - }); - }); - - it("returns null for conversation.statusChanged with missing workspaceId", () => { - expect( - parseServerMessage( - JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - }), - ), - ).toBeNull(); - }); - - it("returns null for conversation.statusChanged with non-string workspaceId", () => { - expect( - parseServerMessage( - JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "active", - workspaceId: 42, - }), - ), - ).toBeNull(); - }); - it("returns null for conversation.statusChanged with invalid status", () => { - expect( - parseServerMessage( - JSON.stringify({ - type: "conversation.statusChanged", - conversationId: "c1", - status: "done", - workspaceId: "w1", - }), - ), - ).toBeNull(); - }); - - it("returns null for conversation.statusChanged with missing conversationId", () => { - expect( - parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })), - ).toBeNull(); - }); + it("parses a catalog message", () => { + const data = JSON.stringify({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + }); + + it("parses a surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + }); + + it("preserves the conversationId echo on a scoped surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: "c1", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: "c1", + }); + }); + + it("rejects a surface message with a non-string conversationId", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: 42, + }); + expect(parseServerMessage(data)).toBeNull(); + }); + + it("parses an update message", () => { + const data = JSON.stringify({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + }); + + it("parses an error message with surfaceId", () => { + const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); + }); + + it("parses an error message without surfaceId", () => { + const data = JSON.stringify({ type: "error", message: "global boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", message: "global boom" }); + }); + + it("returns null for malformed JSON", () => { + expect(parseServerMessage("not json")).toBeNull(); + expect(parseServerMessage("{broken")).toBeNull(); + expect(parseServerMessage("")).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + expect(parseServerMessage("42")).toBeNull(); + expect(parseServerMessage('"hello"')).toBeNull(); + expect(parseServerMessage("null")).toBeNull(); + expect(parseServerMessage("true")).toBeNull(); + expect(parseServerMessage("[1,2,3]")).toBeNull(); + }); + + it("returns null for unknown type", () => { + expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); + }); + + it("returns null when type is missing", () => { + expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); + }); + + it("returns null when type is not a string", () => { + expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); + }); + + it("returns null for catalog with non-array catalog field", () => { + expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); + }); + + it("returns null for surface with missing spec fields", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); + }); + + it("returns null for surface with non-object spec", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); + }); + + it("returns null for update with missing update field", () => { + expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); + }); + + it("returns null for update with invalid spec", () => { + expect( + parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), + ).toBeNull(); + }); + + it("returns null for error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); + }); + + it("returns null for error with invalid surfaceId type", () => { + expect( + parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), + ).toBeNull(); + }); + + it("parses a chat.delta message", () => { + const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" }; + const data = JSON.stringify({ type: "chat.delta", event }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.delta", event }); + }); + + it("parses a chat.error message with conversationId", () => { + const data = JSON.stringify({ + type: "chat.error", + conversationId: "c1", + message: "bad request", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" }); + }); + + it("parses a chat.error message without conversationId", () => { + const data = JSON.stringify({ type: "chat.error", message: "no conversation" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.error", message: "no conversation" }); + }); + + it("returns null for chat.delta with non-object event", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull(); + }); + + it("returns null for chat.delta with missing event.type", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull(); + }); + + it("returns null for chat.error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull(); + }); + + it("returns null for chat.error with invalid conversationId type", () => { + expect( + parseServerMessage( + JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }), + ), + ).toBeNull(); + }); + + it("parses a conversation.open message", () => { + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + }); + + it("returns null for conversation.open with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string conversationId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: 42, + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.open with missing workspaceId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); + + it("parses a conversation.statusChanged message", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + }); + + it("returns null for conversation.statusChanged with missing workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); + it("returns null for conversation.statusChanged with invalid status", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "done", + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })), + ).toBeNull(); + }); }); describe("round-trip: parseServerMessage(serialize(...))", () => { - it("round-trips a subscribe message through serialize only", () => { - const msg = { type: "subscribe" as const, surfaceId: "s1" }; - const wire = serialize(msg); - expect(JSON.parse(wire)).toEqual(msg); - }); - - it("round-trips an invoke message with payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; - const wire = serialize(msg); - expect(JSON.parse(wire)).toEqual(msg); - }); + it("round-trips a subscribe message through serialize only", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); + + it("round-trips an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); }); describe("nextBackoffMs", () => { - it("returns a positive number", () => { - expect(nextBackoffMs(0)).toBeGreaterThan(0); - }); - - it("is capped at 30s + jitter (at most ~36s)", () => { - for (let i = 0; i < 100; i++) { - expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); - } - }); - - it("starts around 500ms (±20% jitter)", () => { - for (let i = 0; i < 100; i++) { - const ms = nextBackoffMs(0); - expect(ms).toBeGreaterThanOrEqual(400); - expect(ms).toBeLessThanOrEqual(600); - } - }); - - it("grows exponentially with attempt", () => { - const averages = [0, 1, 2, 3].map((attempt) => { - let sum = 0; - for (let i = 0; i < 200; i++) { - sum += nextBackoffMs(attempt); - } - return sum / 200; - }); - for (let i = 1; i < averages.length; i++) { - const prev = averages[i - 1]; - if (prev === undefined) throw new Error("unreachable"); - expect(averages[i]).toBeGreaterThan(prev); - } - }); - - it("treats negative attempt as 0", () => { - for (let i = 0; i < 50; i++) { - const ms = nextBackoffMs(-5); - expect(ms).toBeGreaterThanOrEqual(400); - expect(ms).toBeLessThanOrEqual(600); - } - }); + it("returns a positive number", () => { + expect(nextBackoffMs(0)).toBeGreaterThan(0); + }); + + it("is capped at 30s + jitter (at most ~36s)", () => { + for (let i = 0; i < 100; i++) { + expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); + } + }); + + it("starts around 500ms (±20% jitter)", () => { + for (let i = 0; i < 100; i++) { + const ms = nextBackoffMs(0); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); + + it("grows exponentially with attempt", () => { + const averages = [0, 1, 2, 3].map((attempt) => { + let sum = 0; + for (let i = 0; i < 200; i++) { + sum += nextBackoffMs(attempt); + } + return sum / 200; + }); + for (let i = 1; i < averages.length; i++) { + const prev = averages[i - 1]; + if (prev === undefined) throw new Error("unreachable"); + expect(averages[i]).toBeGreaterThan(prev); + } + }); + + it("treats negative attempt as 0", () => { + for (let i = 0; i < 50; i++) { + const ms = nextBackoffMs(-5); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); }); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index d1a5b5f..ba3e7ee 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,38 +1,38 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - ConversationCompactedMessage, - ConversationOpenMessage, - ConversationStatusChangedMessage, - WsClientMessage, - WsServerMessage, + ChatDeltaMessage, + ChatErrorMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + WsClientMessage, + WsServerMessage, } from "@dispatch/transport-contract"; import type { - CatalogMessage, - SurfaceErrorMessage, - SurfaceMessage, - SurfaceUpdateMessage, + CatalogMessage, + SurfaceErrorMessage, + SurfaceMessage, + SurfaceUpdateMessage, } from "@dispatch/ui-contract"; const VALID_SERVER_TYPES = new Set([ - "catalog", - "surface", - "update", - "error", - "chat.delta", - "chat.error", - "conversation.open", - "conversation.statusChanged", - "conversation.compacted", + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + "conversation.statusChanged", + "conversation.compacted", ]); /** Serialize a client message to a JSON string for the wire. */ export function serialize(msg: WsClientMessage): string { - return JSON.stringify(msg); + return JSON.stringify(msg); } function isRecord(v: unknown): v is Record { - return v !== null && typeof v === "object" && !Array.isArray(v); + return v !== null && typeof v === "object" && !Array.isArray(v); } /** @@ -40,121 +40,121 @@ function isRecord(v: unknown): v is Record { * Returns null for malformed JSON or shapes that don't match the protocol. */ export function parseServerMessage(data: string): WsServerMessage | null { - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - return null; - } - if (!isRecord(parsed)) { - return null; - } - const t = parsed.type; - if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { - return null; - } - switch (t) { - case "catalog": { - if (!Array.isArray(parsed.catalog)) return null; - return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; - } - case "surface": { - const spec = parsed.spec; - if (!isRecord(spec)) return null; - if (typeof spec.id !== "string") return null; - if (typeof spec.region !== "string") return null; - if (typeof spec.title !== "string") return null; - if (!Array.isArray(spec.fields)) return null; - // Preserve the conversationId echo (a conversation-scoped surface's initial - // reply carries it) — dropping it would defeat the protocol reducer's - // stale-scope filtering on a fast conversation switch. - const conversationId = parsed.conversationId; - if (conversationId !== undefined && typeof conversationId !== "string") return null; - const surfaceSpec = spec as unknown as SurfaceMessage["spec"]; - return conversationId !== undefined - ? { type: "surface", spec: surfaceSpec, conversationId } - : { type: "surface", spec: surfaceSpec }; - } - case "update": { - const update = parsed.update; - if (!isRecord(update)) return null; - if (typeof update.surfaceId !== "string") return null; - const spec = update.spec; - if (!isRecord(spec)) return null; - if (typeof spec.id !== "string") return null; - if (typeof spec.region !== "string") return null; - if (typeof spec.title !== "string") return null; - if (!Array.isArray(spec.fields)) return null; - return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; - } - case "error": { - if (typeof parsed.message !== "string") return null; - const surfaceId = parsed.surfaceId; - if (surfaceId !== undefined && typeof surfaceId !== "string") return null; - const msg: SurfaceErrorMessage = - surfaceId !== undefined - ? { type: "error", surfaceId, message: parsed.message } - : { type: "error", message: parsed.message }; - return msg; - } - case "chat.delta": { - const event = parsed.event; - if (!isRecord(event)) return null; - if (typeof event.type !== "string") return null; - return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] }; - } - case "chat.error": { - if (typeof parsed.message !== "string") return null; - const conversationId = parsed.conversationId; - if (conversationId !== undefined && typeof conversationId !== "string") return null; - const msg: ChatErrorMessage = - conversationId !== undefined - ? { type: "chat.error", conversationId, message: parsed.message } - : { type: "chat.error", message: parsed.message }; - return msg; - } - case "conversation.open": { - if (typeof parsed.conversationId !== "string") return null; - if (typeof parsed.workspaceId !== "string") return null; - const msg: ConversationOpenMessage = { - type: "conversation.open", - conversationId: parsed.conversationId, - workspaceId: parsed.workspaceId, - }; - return msg; - } - case "conversation.statusChanged": { - if (typeof parsed.conversationId !== "string") return null; - if (typeof parsed.status !== "string") return null; - if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { - return null; - } - if (typeof parsed.workspaceId !== "string") return null; - const msg: ConversationStatusChangedMessage = { - type: "conversation.statusChanged", - conversationId: parsed.conversationId, - status: parsed.status, - workspaceId: parsed.workspaceId, - }; - return msg; - } - case "conversation.compacted": { - if (typeof parsed.conversationId !== "string") return null; - if (typeof parsed.newConversationId !== "string") return null; - if (typeof parsed.messagesSummarized !== "number") return null; - if (typeof parsed.messagesKept !== "number") return null; - const msg: ConversationCompactedMessage = { - type: "conversation.compacted", - conversationId: parsed.conversationId, - newConversationId: parsed.newConversationId, - messagesSummarized: parsed.messagesSummarized, - messagesKept: parsed.messagesKept, - }; - return msg; - } - default: - return null; - } + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (!isRecord(parsed)) { + return null; + } + const t = parsed.type; + if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { + return null; + } + switch (t) { + case "catalog": { + if (!Array.isArray(parsed.catalog)) return null; + return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; + } + case "surface": { + const spec = parsed.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + // Preserve the conversationId echo (a conversation-scoped surface's initial + // reply carries it) — dropping it would defeat the protocol reducer's + // stale-scope filtering on a fast conversation switch. + const conversationId = parsed.conversationId; + if (conversationId !== undefined && typeof conversationId !== "string") return null; + const surfaceSpec = spec as unknown as SurfaceMessage["spec"]; + return conversationId !== undefined + ? { type: "surface", spec: surfaceSpec, conversationId } + : { type: "surface", spec: surfaceSpec }; + } + case "update": { + const update = parsed.update; + if (!isRecord(update)) return null; + if (typeof update.surfaceId !== "string") return null; + const spec = update.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; + } + case "error": { + if (typeof parsed.message !== "string") return null; + const surfaceId = parsed.surfaceId; + if (surfaceId !== undefined && typeof surfaceId !== "string") return null; + const msg: SurfaceErrorMessage = + surfaceId !== undefined + ? { type: "error", surfaceId, message: parsed.message } + : { type: "error", message: parsed.message }; + return msg; + } + case "chat.delta": { + const event = parsed.event; + if (!isRecord(event)) return null; + if (typeof event.type !== "string") return null; + return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] }; + } + case "chat.error": { + if (typeof parsed.message !== "string") return null; + const conversationId = parsed.conversationId; + if (conversationId !== undefined && typeof conversationId !== "string") return null; + const msg: ChatErrorMessage = + conversationId !== undefined + ? { type: "chat.error", conversationId, message: parsed.message } + : { type: "chat.error", message: parsed.message }; + return msg; + } + case "conversation.open": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; + const msg: ConversationOpenMessage = { + type: "conversation.open", + conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, + }; + return msg; + } + case "conversation.statusChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.status !== "string") return null; + if (parsed.status !== "active" && parsed.status !== "idle" && parsed.status !== "closed") { + return null; + } + if (typeof parsed.workspaceId !== "string") return null; + const msg: ConversationStatusChangedMessage = { + type: "conversation.statusChanged", + conversationId: parsed.conversationId, + status: parsed.status, + workspaceId: parsed.workspaceId, + }; + return msg; + } + case "conversation.compacted": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.newConversationId !== "string") return null; + if (typeof parsed.messagesSummarized !== "number") return null; + if (typeof parsed.messagesKept !== "number") return null; + const msg: ConversationCompactedMessage = { + type: "conversation.compacted", + conversationId: parsed.conversationId, + newConversationId: parsed.newConversationId, + messagesSummarized: parsed.messagesSummarized, + messagesKept: parsed.messagesKept, + }; + return msg; + } + default: + return null; + } } /** @@ -162,10 +162,10 @@ export function parseServerMessage(data: string): WsServerMessage | null { * Base: 500ms, doubles each attempt, caps at 30s, adds ±20% jitter. */ export function nextBackoffMs(attempt: number): number { - const base = 500; - const max = 30_000; - const exponential = base * 2 ** Math.max(0, attempt); - const capped = Math.min(exponential, max); - const jitter = 0.8 + Math.random() * 0.4; - return Math.round(capped * jitter); + const base = 500; + const max = 30_000; + const exponential = base * 2 ** Math.max(0, attempt); + const capped = Math.min(exponential, max); + const jitter = 0.8 + Math.random() * 0.4; + return Math.round(capped * jitter); } diff --git a/src/app.css b/src/app.css index 4c59d90..f5b269c 100644 --- a/src/app.css +++ b/src/app.css @@ -9,111 +9,111 @@ applied via ). Themes not listed here are NOT bundled, so monokai must be named explicitly, not merely referenced. */ @plugin "daisyui" { - themes: monokai --default; + themes: monokai --default; } /* Rendered-Markdown (assistant messages) typography — scoped to .markdown-body so it never leaks into the rest of the app. */ .markdown-body { - & p { - margin-block: 0.5em; - &:first-child { - margin-block-start: 0; - } - &:last-child { - margin-block-end: 0; - } - } - & h1, - & h2, - & h3, - & h4, - & h5, - & h6 { - font-weight: 600; - line-height: 1.25; - margin-block: 0.75em 0.25em; - &:first-child { - margin-block-start: 0; - } - } - & h1 { - font-size: 1.4em; - } - & h2 { - font-size: 1.2em; - } - & h3 { - font-size: 1.1em; - } - & ul, - & ol { - padding-inline-start: 1.5em; - margin-block: 0.5em; - } - & ul { - list-style-type: disc; - } - & ol { - list-style-type: decimal; - } - & li { - margin-block: 0.15em; - } - & pre { - overflow-x: auto; - border-radius: var(--radius-box); - margin-block: 0.5em; - } - & pre code { - display: block; - padding: 0.75em 1em; - font-size: 0.8125em; - line-height: 1.5; - } - & :not(pre) > code { - font-size: 0.875em; - padding: 0.15em 0.4em; - border-radius: var(--radius-selector); - background-color: oklch(var(--color-base-content) / 0.1); - } - & blockquote { - border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2); - padding-inline-start: 0.75em; - margin-block: 0.5em; - opacity: 0.8; - } - & a { - color: oklch(var(--color-primary)); - text-decoration: underline; - &:hover { - opacity: 0.8; - } - } - & strong { - font-weight: 600; - } - & table { - width: 100%; - border-collapse: collapse; - margin-block: 0.5em; - font-size: 0.875em; - } - & th, - & td { - border: 1px solid oklch(var(--color-base-content) / 0.15); - padding: 0.4em 0.75em; - text-align: start; - } - & th { - font-weight: 600; - background-color: oklch(var(--color-base-200)); - } - & hr { - border: none; - border-top: 1px solid oklch(var(--color-base-content) / 0.2); - margin-block: 0.75em; - } + & p { + margin-block: 0.5em; + &:first-child { + margin-block-start: 0; + } + &:last-child { + margin-block-end: 0; + } + } + & h1, + & h2, + & h3, + & h4, + & h5, + & h6 { + font-weight: 600; + line-height: 1.25; + margin-block: 0.75em 0.25em; + &:first-child { + margin-block-start: 0; + } + } + & h1 { + font-size: 1.4em; + } + & h2 { + font-size: 1.2em; + } + & h3 { + font-size: 1.1em; + } + & ul, + & ol { + padding-inline-start: 1.5em; + margin-block: 0.5em; + } + & ul { + list-style-type: disc; + } + & ol { + list-style-type: decimal; + } + & li { + margin-block: 0.15em; + } + & pre { + overflow-x: auto; + border-radius: var(--radius-box); + margin-block: 0.5em; + } + & pre code { + display: block; + padding: 0.75em 1em; + font-size: 0.8125em; + line-height: 1.5; + } + & :not(pre) > code { + font-size: 0.875em; + padding: 0.15em 0.4em; + border-radius: var(--radius-selector); + background-color: oklch(var(--color-base-content) / 0.1); + } + & blockquote { + border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2); + padding-inline-start: 0.75em; + margin-block: 0.5em; + opacity: 0.8; + } + & a { + color: oklch(var(--color-primary)); + text-decoration: underline; + &:hover { + opacity: 0.8; + } + } + & strong { + font-weight: 600; + } + & table { + width: 100%; + border-collapse: collapse; + margin-block: 0.5em; + font-size: 0.875em; + } + & th, + & td { + border: 1px solid oklch(var(--color-base-content) / 0.15); + padding: 0.4em 0.75em; + text-align: start; + } + & th { + font-weight: 600; + background-color: oklch(var(--color-base-200)); + } + & hr { + border: none; + border-top: 1px solid oklch(var(--color-base-content) / 0.2); + margin-block: 0.75em; + } } /* App shell fills the viewport and never scrolls/overflows at the page level — @@ -121,9 +121,9 @@ html, body, #app { - height: 100%; + height: 100%; } body { - overflow: hidden; + overflow: hidden; } diff --git a/src/app/App.svelte b/src/app/App.svelte index 78504bb..a3acaeb 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,613 +1,614 @@
- -
- -
- store.conversationStatus(id)} - onSelect={(id) => store.selectTab(id)} - onClose={(id) => store.closeTab(id)} - onNewDraft={() => store.newDraft()} - onRename={(id, title) => store.renameTab(id, title)} - /> - - {__APP_VERSION__} - - -
- - {#if store.lastError} - - {/if} - - {#if store.activeChat.error} - - {/if} - -
-
-
- {#key store.activeConversationId} - - {/key} -
-
- {#if store.activeChat.chunks.length === 0} - - {/if} - smartScroll.resume()} /> -
- - {#if hasQueuedMessages && messageQueueSpec !== null} - -
- -
- {/if} - - -
- - - - - - {#if sidebarOpen} - -
(sidebarOpen = false)} - onkeydown={(e) => { - if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false; - }} - >
- {/if} + +
+ +
+ store.conversationStatus(id)} + onSelect={(id) => store.selectTab(id)} + onClose={(id) => store.closeTab(id)} + onNewDraft={() => store.newDraft()} + onRename={(id, title) => store.renameTab(id, title)} + /> + + {__APP_VERSION__} + + +
+ + {#if store.lastError} + + {/if} + + {#if store.activeChat.error} + + {/if} + +
+
+
+ {#key store.activeConversationId} + + {/key} +
+
+ {#if store.activeChat.chunks.length === 0} + + {/if} + smartScroll.resume()} /> +
+ + {#if hasQueuedMessages && messageQueueSpec !== null} + +
+ +
+ {/if} + + +
+ + + + + + {#if sidebarOpen} + +
(sidebarOpen = false)} + onkeydown={(e) => { + if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false; + }} + >
+ {/if}
{#if store.fatalError} - store.clearFatalError()} /> + store.clearFatalError()} /> {/if} {#if systemPromptModalOpen} - (systemPromptModalOpen = false)} - /> + (systemPromptModalOpen = false)} + /> {/if} {#snippet viewContent(kind: string)} - {#if kind === "model"} -
- - - {#key store.currentConversationId} - - - - {/key} -
- {:else if kind === "lsp"} - - {#key store.currentConversationId} - - {/key} - {:else if kind === "mcp"} - - {#key store.currentConversationId} - - {/key} - {:else if kind === "extensions"} -
-

Frontend modules

- - -
-

Surfaces

- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} - - {/each} -
- {:else if kind === "cache-warming"} - - {#key store.activeConversationId} - - {/key} - {:else if kind === "tasks"} - - {#key store.activeConversationId} - {#if todoData !== null && todoData.todos.length > 0} - - {:else} -

No tasks yet.

- {/if} - {/key} - {:else if kind === "compaction"} - - {#key store.currentConversationId} - - {/key} - {:else if kind === "system-prompt"} - -
-

- Edit the global system prompt template with variable placeholders. Opens a full-page editor. -

- -
- {:else if kind === "settings"} - -
- -
- {/if} + {#if kind === "model"} +
+ + + {#key store.currentConversationId} + + + + {/key} +
+ {:else if kind === "lsp"} + + {#key store.currentConversationId} + + {/key} + {:else if kind === "mcp"} + + {#key store.currentConversationId} + + {/key} + {:else if kind === "extensions"} +
+

Frontend modules

+
+ +
+

Surfaces

+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)} + + {/each} +
+ {:else if kind === "cache-warming"} + + {#key store.activeConversationId} + + {/key} + {:else if kind === "tasks"} + + {#key store.activeConversationId} + {#if todoData !== null && todoData.todos.length > 0} + + {:else} +

No tasks yet.

+ {/if} + {/key} + {:else if kind === "compaction"} + + {#key store.currentConversationId} + + {/key} + {:else if kind === "system-prompt"} + +
+

+ Edit the global system prompt template with variable placeholders. Opens a full-page editor. +

+ +
+ {:else if kind === "settings"} + +
+ +
+ {/if} {/snippet} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index 1a93948..7c0a851 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -8,517 +8,517 @@ import App from "./App.svelte"; import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - feedServerMessage(data: WsServerMessage): void; - feedSurfaceMessage(data: SurfaceServerMessage): void; + sent: string[]; + resolveOpen(): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - const sent: string[] = []; - - const ws: FakeSocket = { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return null; - }, - set onclose(_fn) {}, - resolveOpen() { - onopen?.(); - }, - feedServerMessage(msg: WsServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - feedSurfaceMessage(msg: SurfaceServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - sent, - }; - return ws; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; } function fakeFetchImpl(): typeof fetch { - return async (input: string | URL | Request): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - if (url.includes("/conversations?status=")) { - return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); - } - if (url.endsWith("/cwd")) { - return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); - } - if (url.endsWith("/lsp")) { - return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { - status: 200, - }); - } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); - }; + return async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + if (url.includes("/conversations?status=")) { + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; } function createFakeStorage(): Storage { - const map = new Map(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string): string | null { - return map.get(key) ?? null; - }, - key(_index: number): string | null { - return null; - }, - removeItem(key: string) { - map.delete(key); - }, - setItem(key: string, value: string) { - map.set(key, value); - }, - }; + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } function createFakeStorageWithViews(views: readonly string[] = ["extensions"]): Storage { - const storage = createFakeStorage(); - storage.setItem("dispatch.sidebar.views", JSON.stringify(views)); - return storage; + const storage = createFakeStorage(); + storage.setItem("dispatch.sidebar.views", JSON.stringify(views)); + return storage; } function sentMessages(ws: FakeSocket) { - return ws.sent.map((s) => JSON.parse(s)); + return ws.sent.map((s) => JSON.parse(s)); } function activeConversationId(store: ReturnType): string { - const id = store.activeConversationId; - expect(id).not.toBeNull(); - return id as string; + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; } describe("App component interaction tests", () => { - it("renders the model selector and composer in draft mode", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); - expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument(); - - store.dispose(); - }); - - it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.sent.length = 0; - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - render(App, { props: { store } }); - - const subscribed = sentMessages(ws) - .filter((m: { type: string }) => m.type === "subscribe") - .map((m: { surfaceId: string }) => m.surfaceId); - expect(subscribed).toContain("s1"); - expect(subscribed).toContain("s2"); - - store.dispose(); - }); - - it("renders every surface expanded once their specs arrive", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - render(App, { props: { store } }); - - // No interaction: specs arrive and both surfaces render expanded. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], - }, - }); - ws.feedSurfaceMessage({ - type: "surface", - spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, - }); - - expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); - expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument(); - expect(await screen.findByText("Tokens")).toBeInTheDocument(); - expect(await screen.findByText("1,234")).toBeInTheDocument(); - - store.dispose(); - }); - - it("an error message renders the alert banner", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "error", - message: "Something went wrong", - }); - - render(App, { props: { store } }); - - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent("Something went wrong"); - - store.dispose(); - }); - - it("invoking a field action sends an invoke", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], - }); - - render(App, { props: { store } }); - - const user = userEvent.setup(); - // Surface is auto-subscribed; its spec arrives and renders expanded. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [ - { - kind: "toggle", - label: "Dark Mode", - value: false, - action: { actionId: "toggle-dark" }, - }, - ], - }, - }); - - ws.sent.length = 0; - const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" }); - await user.click(checkbox); - - const msgs = sentMessages(ws); - const invoke = msgs.find( - (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) => - m.type === "invoke" && - m.surfaceId === "s1" && - m.actionId === "toggle-dark" && - m.payload === true, - ); - expect(invoke).toBeTruthy(); - - store.dispose(); - }); - - it("typing and sending a message posts chat.send on the socket", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - const user = userEvent.setup(); - const textarea = screen.getByRole("textbox", { name: "Message input" }); - await user.type(textarea, "hello from UI"); - - ws.sent.length = 0; - const sendBtn = screen.getByRole("button", { name: "Send" }); - await user.click(sendBtn); - - const msgs = sentMessages(ws); - const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as - | { type: string; conversationId: string; message: string } - | undefined; - expect(chatSend).toBeTruthy(); - expect(chatSend?.message).toBe("hello from UI"); - - store.dispose(); - }); - - it("incoming chat.delta renders text in the chat transcript", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - // Promote draft to tab - store.send("test"); - const convId = activeConversationId(store); - - render(App, { props: { store } }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { - type: "turn-start", - conversationId: convId, - turnId: "turn-1", - }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { - type: "text-delta", - conversationId: convId, - turnId: "turn-1", - delta: "Hi there!", - }, - }); - - expect(await screen.findByText("Hi there!")).toBeInTheDocument(); - - store.dispose(); - }); - - it("renders a custom 'table' field of a surface as a table", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], - }); - - render(App, { props: { store } }); - - // Auto-subscribed; the custom-table spec arrives and renders expanded. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [ - { - kind: "custom", - rendererId: "table", - payload: { - columns: ["Name", "Scope"], - rows: [["cache-warm", "backend"]], - }, - }, - ], - }, - }); - - expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument(); - expect(await screen.findByText("cache-warm")).toBeInTheDocument(); - expect(await screen.findByText("backend")).toBeInTheDocument(); - - store.dispose(); - }); - - it("the Extensions view lists frontend modules aggregated from feature manifests", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorageWithViews(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. - expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); - for (const name of [ - "chat", - "tabs", - "surface-host", - "views", - "conversation-cache", - "markdown", - ]) { - expect(screen.getByRole("cell", { name })).toBeInTheDocument(); - } - - store.dispose(); - }); - - it("shows a full-screen error modal when fetchOpenConversations fails", async () => { - // A fetch that throws for the conversations list endpoint (simulating a - // network failure / unreachable backend on a new device). - const failingFetch = async (input: string | URL | Request): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - if (url.includes("/conversations?status=")) { - throw new TypeError("Failed to fetch: network error"); - } - if (url.endsWith("/cwd")) { - return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); - } - if (url.endsWith("/lsp")) { - return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { - status: 200, - }); - } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); - }; - - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: failingFetch, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - // The modal should appear with the error text - const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 }); - expect(dialog).toBeInTheDocument(); - expect(dialog).toHaveTextContent("Failed to load conversations"); - expect(dialog).toHaveTextContent("TypeError"); - expect(dialog).toHaveTextContent("network error"); - - // Dismiss button clears the modal - const dismissBtn = screen.getByRole("button", { name: "Dismiss error" }); - await userEvent.setup().click(dismissBtn); - expect(store.fatalError).toBeNull(); - - store.dispose(); - }); - - it("does not show the error modal when fetchOpenConversations succeeds", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), // returns valid empty conversations list - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - render(App, { props: { store } }); - - // Wait a tick for boot async to settle - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(store.fatalError).toBeNull(); - - store.dispose(); - }); - - it("sends workspaceId when setting cwd", async () => { - let capturedBody: SetCwdRequest | undefined; - const fetchWithCapture = async ( - input: string | URL | Request, - init?: RequestInit, - ): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/cwd") && init?.method === "PUT") { - capturedBody = JSON.parse(init.body as string) as SetCwdRequest; - return new Response(JSON.stringify({ conversationId: "c", cwd: capturedBody.cwd }), { - status: 200, - }); - } - return fakeFetchImpl()(input); - }; - - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fetchWithCapture, - localStorage: createFakeStorage(), - workspaceId: "my-team", - }); - ws.resolveOpen(); - - // Let async boot settle before mutating cwd. - await new Promise((resolve) => setTimeout(resolve, 50)); - - const result = await store.setCwd("arch-rewrite"); - expect(result).toMatchObject({ ok: true, cwd: "arch-rewrite" }); - expect(capturedBody).toEqual({ cwd: "arch-rewrite", workspaceId: "my-team" }); - - store.dispose(); - }); + it("renders the model selector and composer in draft mode", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Model selector" })).toBeInTheDocument(); + + store.dispose(); + }); + + it("auto-subscribes to every catalog entry on render (no buttons to click)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.sent.length = 0; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + const subscribed = sentMessages(ws) + .filter((m: { type: string }) => m.type === "subscribe") + .map((m: { surfaceId: string }) => m.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); + + store.dispose(); + }); + + it("renders every surface expanded once their specs arrive", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + render(App, { props: { store } }); + + // No interaction: specs arrive and both surfaces render expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); + + expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Surface Two" })).toBeInTheDocument(); + expect(await screen.findByText("Tokens")).toBeInTheDocument(); + expect(await screen.findByText("1,234")).toBeInTheDocument(); + + store.dispose(); + }); + + it("an error message renders the alert banner", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "error", + message: "Something went wrong", + }); + + render(App, { props: { store } }); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Something went wrong"); + + store.dispose(); + }); + + it("invoking a field action sends an invoke", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + // Surface is auto-subscribed; its spec arrives and renders expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "toggle", + label: "Dark Mode", + value: false, + action: { actionId: "toggle-dark" }, + }, + ], + }, + }); + + ws.sent.length = 0; + const checkbox = await screen.findByRole("checkbox", { name: "Dark Mode" }); + await user.click(checkbox); + + const msgs = sentMessages(ws); + const invoke = msgs.find( + (m: { type: string; surfaceId: string; actionId: string; payload: unknown }) => + m.type === "invoke" && + m.surfaceId === "s1" && + m.actionId === "toggle-dark" && + m.payload === true, + ); + expect(invoke).toBeTruthy(); + + store.dispose(); + }); + + it("typing and sending a message posts chat.send on the socket", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "hello from UI"); + + ws.sent.length = 0; + const sendBtn = screen.getByRole("button", { name: "Send" }); + await user.click(sendBtn); + + const msgs = sentMessages(ws); + const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as + | { type: string; conversationId: string; message: string } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.message).toBe("hello from UI"); + + store.dispose(); + }); + + it("incoming chat.delta renders text in the chat transcript", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // Promote draft to tab + store.send("test"); + const convId = activeConversationId(store); + + render(App, { props: { store } }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: convId, + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: convId, + turnId: "turn-1", + delta: "Hi there!", + }, + }); + + expect(await screen.findByText("Hi there!")).toBeInTheDocument(); + + store.dispose(); + }); + + it("renders a custom 'table' field of a surface as a table", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + render(App, { props: { store } }); + + // Auto-subscribed; the custom-table spec arrives and renders expanded. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [ + { + kind: "custom", + rendererId: "table", + payload: { + columns: ["Name", "Scope"], + rows: [["cache-warm", "backend"]], + }, + }, + ], + }, + }); + + expect(await screen.findByRole("columnheader", { name: "Name" })).toBeInTheDocument(); + expect(await screen.findByText("cache-warm")).toBeInTheDocument(); + expect(await screen.findByText("backend")).toBeInTheDocument(); + + store.dispose(); + }); + + it("the Extensions view lists frontend modules aggregated from feature manifests", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorageWithViews(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. + expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); + for (const name of [ + "chat", + "tabs", + "surface-host", + "views", + "conversation-cache", + "markdown", + ]) { + expect(screen.getByRole("cell", { name })).toBeInTheDocument(); + } + + store.dispose(); + }); + + it("shows a full-screen error modal when fetchOpenConversations fails", async () => { + // A fetch that throws for the conversations list endpoint (simulating a + // network failure / unreachable backend on a new device). + const failingFetch = async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + if (url.includes("/conversations?status=")) { + throw new TypeError("Failed to fetch: network error"); + } + if (url.endsWith("/cwd")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null }), { status: 200 }); + } + if (url.endsWith("/lsp")) { + return new Response(JSON.stringify({ conversationId: "c", cwd: null, servers: [] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: failingFetch, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // The modal should appear with the error text + const dialog = await screen.findByRole("dialog", { name: "Error" }, { timeout: 3000 }); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent("Failed to load conversations"); + expect(dialog).toHaveTextContent("TypeError"); + expect(dialog).toHaveTextContent("network error"); + + // Dismiss button clears the modal + const dismissBtn = screen.getByRole("button", { name: "Dismiss error" }); + await userEvent.setup().click(dismissBtn); + expect(store.fatalError).toBeNull(); + + store.dispose(); + }); + + it("does not show the error modal when fetchOpenConversations succeeds", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), // returns valid empty conversations list + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + // Wait a tick for boot async to settle + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(store.fatalError).toBeNull(); + + store.dispose(); + }); + + it("sends workspaceId when setting cwd", async () => { + let capturedBody: SetCwdRequest | undefined; + const fetchWithCapture = async ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/cwd") && init?.method === "PUT") { + capturedBody = JSON.parse(init.body as string) as SetCwdRequest; + return new Response(JSON.stringify({ conversationId: "c", cwd: capturedBody.cwd }), { + status: 200, + }); + } + return fakeFetchImpl()(input); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fetchWithCapture, + localStorage: createFakeStorage(), + workspaceId: "my-team", + }); + ws.resolveOpen(); + + // Let async boot settle before mutating cwd. + await new Promise((resolve) => setTimeout(resolve, 50)); + + const result = await store.setCwd("arch-rewrite"); + expect(result).toMatchObject({ ok: true, cwd: "arch-rewrite" }); + expect(capturedBody).toEqual({ cwd: "arch-rewrite", workspaceId: "my-team" }); + + store.dispose(); + }); }); diff --git a/src/app/ErrorModal.svelte b/src/app/ErrorModal.svelte index 7fc09a6..0415827 100644 --- a/src/app/ErrorModal.svelte +++ b/src/app/ErrorModal.svelte @@ -1,50 +1,50 @@ @@ -52,100 +52,99 @@ diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts index 90edcbb..0da2591 100644 --- a/src/app/resolve-http-url.test.ts +++ b/src/app/resolve-http-url.test.ts @@ -2,55 +2,55 @@ import { describe, expect, it } from "vitest"; import { resolveHttpUrl } from "./resolve-http-url"; describe("resolveHttpUrl", () => { - it("explicit url wins over everything", () => { - const result = resolveHttpUrl( - { VITE_HTTP_URL: "https://env.example.com:9999" }, - { protocol: "https:", hostname: "page.example.com" }, - ); - expect(result).toBe("https://env.example.com:9999"); - }); - - it("VITE_HTTP_URL wins over derivation", () => { - const result = resolveHttpUrl( - { VITE_HTTP_URL: "https://env.example.com:8888" }, - { protocol: "http:", hostname: "page.example.com" }, - ); - expect(result).toBe("https://env.example.com:8888"); - }); - - it("derives http://:24203 from http location", () => { - const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); - expect(result).toBe("http://100.126.75.103:24203"); - }); - - it("derives https://:24203 from https location", () => { - const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("https://arch-razer:24203"); - }); - - it("uses VITE_HTTP_PORT when set", () => { - const result = resolveHttpUrl( - { VITE_HTTP_PORT: "3000" }, - { protocol: "http:", hostname: "localhost" }, - ); - expect(result).toBe("http://localhost:3000"); - }); - - it("falls back to http://localhost:24203 when location is missing", () => { - const result = resolveHttpUrl({}); - expect(result).toBe("http://localhost:24203"); - }); - - it("VITE_HTTP_URL empty string treated as unset", () => { - const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" }); - expect(result).toBe("http://myhost:24203"); - }); - - it("VITE_HTTP_PORT empty string falls back to default", () => { - const result = resolveHttpUrl( - { VITE_HTTP_PORT: "" }, - { protocol: "http:", hostname: "localhost" }, - ); - expect(result).toBe("http://localhost:24203"); - }); + it("explicit url wins over everything", () => { + const result = resolveHttpUrl( + { VITE_HTTP_URL: "https://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("https://env.example.com:9999"); + }); + + it("VITE_HTTP_URL wins over derivation", () => { + const result = resolveHttpUrl( + { VITE_HTTP_URL: "https://env.example.com:8888" }, + { protocol: "http:", hostname: "page.example.com" }, + ); + expect(result).toBe("https://env.example.com:8888"); + }); + + it("derives http://:24203 from http location", () => { + const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); + expect(result).toBe("http://100.126.75.103:24203"); + }); + + it("derives https://:24203 from https location", () => { + const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); + expect(result).toBe("https://arch-razer:24203"); + }); + + it("uses VITE_HTTP_PORT when set", () => { + const result = resolveHttpUrl( + { VITE_HTTP_PORT: "3000" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("http://localhost:3000"); + }); + + it("falls back to http://localhost:24203 when location is missing", () => { + const result = resolveHttpUrl({}); + expect(result).toBe("http://localhost:24203"); + }); + + it("VITE_HTTP_URL empty string treated as unset", () => { + const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" }); + expect(result).toBe("http://myhost:24203"); + }); + + it("VITE_HTTP_PORT empty string falls back to default", () => { + const result = resolveHttpUrl( + { VITE_HTTP_PORT: "" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("http://localhost:24203"); + }); }); diff --git a/src/app/resolve-http-url.ts b/src/app/resolve-http-url.ts index 357d2fc..1e20eb2 100644 --- a/src/app/resolve-http-url.ts +++ b/src/app/resolve-http-url.ts @@ -1,28 +1,28 @@ export interface HttpUrlEnv { - readonly VITE_HTTP_URL?: string; - readonly VITE_HTTP_PORT?: string; + readonly VITE_HTTP_URL?: string; + readonly VITE_HTTP_PORT?: string; } export interface HttpUrlLocation { - readonly protocol: string; - readonly hostname: string; + readonly protocol: string; + readonly hostname: string; } const DEFAULT_PORT = "24203"; const DEFAULT_FALLBACK = "http://localhost:24203"; export function resolveHttpUrl(env: HttpUrlEnv, location?: HttpUrlLocation): string { - if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") { - return env.VITE_HTTP_URL; - } + if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") { + return env.VITE_HTTP_URL; + } - if (location === undefined) { - return DEFAULT_FALLBACK; - } + if (location === undefined) { + return DEFAULT_FALLBACK; + } - const port = - env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== "" - ? env.VITE_HTTP_PORT - : DEFAULT_PORT; - return `${location.protocol}//${location.hostname}:${port}`; + const port = + env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== "" + ? env.VITE_HTTP_PORT + : DEFAULT_PORT; + return `${location.protocol}//${location.hostname}:${port}`; } diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts index 24c2f24..b5ba6c3 100644 --- a/src/app/resolve-ws-url.test.ts +++ b/src/app/resolve-ws-url.test.ts @@ -2,52 +2,52 @@ import { describe, expect, it } from "vitest"; import { resolveWsUrl } from "./resolve-ws-url"; describe("resolveWsUrl", () => { - it("explicit url wins over everything", () => { - const result = resolveWsUrl( - { VITE_WS_URL: "wss://env.example.com:9999" }, - { protocol: "https:", hostname: "page.example.com" }, - ); - expect(result).toBe("wss://env.example.com:9999"); - }); - - it("VITE_WS_URL wins over derivation", () => { - const result = resolveWsUrl( - { VITE_WS_URL: "wss://env.example.com:9999" }, - { protocol: "https:", hostname: "page.example.com" }, - ); - expect(result).toBe("wss://env.example.com:9999"); - }); - - it("derives ws://:24205 from http location", () => { - const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); - expect(result).toBe("ws://100.126.75.103:24205"); - }); - - it("derives wss://:24205 from https location", () => { - const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); - expect(result).toBe("wss://arch-razer:24205"); - }); - - it("uses VITE_WS_PORT when set", () => { - const result = resolveWsUrl( - { VITE_WS_PORT: "3000" }, - { protocol: "http:", hostname: "localhost" }, - ); - expect(result).toBe("ws://localhost:3000"); - }); - - it("falls back to ws://localhost:24205 when location is missing", () => { - const result = resolveWsUrl({}); - expect(result).toBe("ws://localhost:24205"); - }); - - it("VITE_WS_URL empty string treated as unset", () => { - const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" }); - expect(result).toBe("ws://myhost:24205"); - }); - - it("VITE_WS_PORT empty string falls back to default", () => { - const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" }); - expect(result).toBe("ws://localhost:24205"); - }); + it("explicit url wins over everything", () => { + const result = resolveWsUrl( + { VITE_WS_URL: "wss://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("wss://env.example.com:9999"); + }); + + it("VITE_WS_URL wins over derivation", () => { + const result = resolveWsUrl( + { VITE_WS_URL: "wss://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("wss://env.example.com:9999"); + }); + + it("derives ws://:24205 from http location", () => { + const result = resolveWsUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); + expect(result).toBe("ws://100.126.75.103:24205"); + }); + + it("derives wss://:24205 from https location", () => { + const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" }); + expect(result).toBe("wss://arch-razer:24205"); + }); + + it("uses VITE_WS_PORT when set", () => { + const result = resolveWsUrl( + { VITE_WS_PORT: "3000" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("ws://localhost:3000"); + }); + + it("falls back to ws://localhost:24205 when location is missing", () => { + const result = resolveWsUrl({}); + expect(result).toBe("ws://localhost:24205"); + }); + + it("VITE_WS_URL empty string treated as unset", () => { + const result = resolveWsUrl({ VITE_WS_URL: "" }, { protocol: "http:", hostname: "myhost" }); + expect(result).toBe("ws://myhost:24205"); + }); + + it("VITE_WS_PORT empty string falls back to default", () => { + const result = resolveWsUrl({ VITE_WS_PORT: "" }, { protocol: "http:", hostname: "localhost" }); + expect(result).toBe("ws://localhost:24205"); + }); }); diff --git a/src/app/resolve-ws-url.ts b/src/app/resolve-ws-url.ts index a264606..1c6e259 100644 --- a/src/app/resolve-ws-url.ts +++ b/src/app/resolve-ws-url.ts @@ -1,27 +1,27 @@ export interface WsUrlEnv { - readonly VITE_WS_URL?: string; - readonly VITE_WS_PORT?: string; + readonly VITE_WS_URL?: string; + readonly VITE_WS_PORT?: string; } export interface WsUrlLocation { - readonly protocol: string; - readonly hostname: string; + readonly protocol: string; + readonly hostname: string; } const DEFAULT_PORT = "24205"; const DEFAULT_FALLBACK = "ws://localhost:24205"; export function resolveWsUrl(env: WsUrlEnv, location?: WsUrlLocation): string { - if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") { - return env.VITE_WS_URL; - } + if (env.VITE_WS_URL !== undefined && env.VITE_WS_URL !== "") { + return env.VITE_WS_URL; + } - if (location === undefined) { - return DEFAULT_FALLBACK; - } + if (location === undefined) { + return DEFAULT_FALLBACK; + } - const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; - const port = - env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT; - return `${wsProtocol}://${location.hostname}:${port}`; + const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; + const port = + env.VITE_WS_PORT !== undefined && env.VITE_WS_PORT !== "" ? env.VITE_WS_PORT : DEFAULT_PORT; + return `${wsProtocol}://${location.hostname}:${port}`; } diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 1d69391..92fcd92 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,38 +1,38 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - CompactPercentResponse, - CompactResponse, - ComputerListResponse, - ComputerStatusResponse, - ConversationCompactedMessage, - ConversationComputerResponse, - ConversationHistoryResponse, - ConversationListResponse, - ConversationMetricsResponse, - ConversationOpenMessage, - ConversationStatusChangedMessage, - CwdResponse, - LspStatusResponse, - McpStatusResponse, - ModelMetadata, - ModelResponse, - ModelsResponse, - ReasoningEffort, - ReasoningEffortResponse, - SetCompactPercentRequest, - SetConversationComputerRequest, - SetCwdRequest, - SetModelRequest, - SetReasoningEffortRequest, - SetSystemPromptTemplateRequest, - SetTitleRequest, - SystemPromptTemplateResponse, - SystemPromptVariable, - SystemPromptVariablesResponse, - TestComputerResponse, - WarmRequest, - WarmResponse, + ChatDeltaMessage, + ChatErrorMessage, + CompactPercentResponse, + CompactResponse, + ComputerListResponse, + ComputerStatusResponse, + ConversationCompactedMessage, + ConversationComputerResponse, + ConversationHistoryResponse, + ConversationListResponse, + ConversationMetricsResponse, + ConversationOpenMessage, + ConversationStatusChangedMessage, + CwdResponse, + LspStatusResponse, + McpStatusResponse, + ModelMetadata, + ModelResponse, + ModelsResponse, + ReasoningEffort, + ReasoningEffortResponse, + SetCompactPercentRequest, + SetConversationComputerRequest, + SetCwdRequest, + SetModelRequest, + SetReasoningEffortRequest, + SetSystemPromptTemplateRequest, + SetTitleRequest, + SystemPromptTemplateResponse, + SystemPromptVariable, + SystemPromptVariablesResponse, + TestComputerResponse, + WarmRequest, + WarmResponse, } from "@dispatch/transport-contract"; import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; import type { ComputerEntry, ConversationStatus } from "@dispatch/wire"; @@ -43,13 +43,13 @@ import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { normalizeChatLimit } from "../core/chunks"; import { - applyServerMessage, - getSurfaceSpec, - type ProtocolState, - initialState as protocolInitialState, - invoke as protocolInvoke, - subscribe as protocolSubscribe, - unsubscribe as protocolUnsubscribe, + applyServerMessage, + getSurfaceSpec, + type ProtocolState, + initialState as protocolInitialState, + invoke as protocolInvoke, + subscribe as protocolSubscribe, + unsubscribe as protocolUnsubscribe, } from "../core/protocol"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; @@ -65,1475 +65,1475 @@ const DEFAULT_MODEL = "opencode/deepseek-v4-flash"; /** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ export type WarmResult = - | { readonly ok: true; readonly response: WarmResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: WarmResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/cwd`. */ export type CwdResult = - | { readonly ok: true; readonly cwd: string | null } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */ export type ComputerResult = - | { readonly ok: true; readonly computerId: string | null } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly computerId: string | null } + | { readonly ok: false; readonly error: string }; /** Outcome of `GET /computers/:alias/status` (the live connection state). */ export type ComputerStatusResult = - | { readonly ok: true; readonly response: ComputerStatusResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: ComputerStatusResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */ export type TestComputerResult = - | { readonly ok: true; readonly response: TestComputerResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: TestComputerResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `GET /conversations/:id/lsp`. */ export type LspResult = - | { readonly ok: true; readonly response: LspStatusResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: LspStatusResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `GET /conversations/:id/mcp`. */ export type McpResult = - | { readonly ok: true; readonly response: McpStatusResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: McpStatusResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/reasoning-effort`. */ export type ReasoningEffortResult = - | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } + | { readonly ok: false; readonly error: string }; /** Outcome of `POST /conversations/:id/compact` (manual compaction). */ export type CompactResult = - | { readonly ok: true; readonly response: CompactResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: CompactResponse } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/compact-percent`. */ export type CompactPercentResult = - | { readonly ok: true; readonly percent: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly percent: number } + | { readonly ok: false; readonly error: string }; /** Outcome of `GET /system-prompt` (global template load). */ export type SystemPromptLoadResult = - | { readonly ok: true; readonly template: string } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /system-prompt` (global template save). */ export type SystemPromptSaveResult = SystemPromptLoadResult; /** Outcome of `GET /system-prompt/variables` (variable catalog). */ export type SystemPromptVariablesResult = - | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } + | { readonly ok: false; readonly error: string }; /** Outcome of persisting a chat-limit setting (localStorage; FE-local). */ export type ChatLimitResult = - | { readonly ok: true; readonly chatLimit: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly chatLimit: number } + | { readonly ok: false; readonly error: string }; export interface AppStore { - readonly tabs: readonly Tab[]; - readonly activeConversationId: string | null; - /** The workspace currently in view (URL slug); tabs are filtered to it. */ - readonly activeWorkspaceId: string; - readonly activeChat: ChatStore; - readonly models: readonly string[]; - /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ - readonly modelInfo: Readonly>; - readonly activeModel: string; - readonly catalog: ProtocolState["catalog"]; - /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ - readonly surfaces: readonly SurfaceSpec[]; - readonly lastError: ProtocolState["lastError"]; - /** The localStorage instance the store uses for persistence (tabs, chatLimit). - * Exposed so the shell can persist sidebar layout via the same adapter. */ - readonly storage: Storage | undefined; - /** The current spec for one surface by id (discovery-by-id), or null if absent. */ - surface(surfaceId: string): SurfaceSpec | null; - send(text: string): void; - /** - * Enqueue a steering message onto the focused conversation's queue - * (`chat.queue` WS op). While a turn is generating, the message is delivered - * mid-turn at the next tool-result boundary; when idle, the server - * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user - * wants to add input — the server owns the idle-vs-generating decision. - */ - queueMessage(text: string): void; - selectModel(model: string): void; - newDraft(): void; - /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ - setActiveWorkspace(workspaceId: string): void; - selectTab(conversationId: string): void; - closeTab(conversationId: string): void; - renameTab(conversationId: string, title: string): void; - invoke(surfaceId: string, actionId: string, payload?: unknown): void; - /** - * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). - * Returns null when no conversation is focused (a draft has nothing to warm). - */ - warmNow(): Promise; - /** The workspace conversation's persisted working directory, or null when unset. */ - readonly cwd: string | null; - /** The conversation workspace settings target: the active tab, or the pending draft's id. */ - readonly currentConversationId: string; - /** - * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). - * Works for a draft too (its id survives promotion), so the first turn runs in it. - */ - setCwd(cwd: string): Promise; - /** - * The workspace conversation's persisted computer (an SSH `Host` alias), or - * null when never set / local. Seeded from the backend on focus change. - */ - readonly computerId: string | null; - /** - * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`). - * Pass null to clear → the conversation inherits the workspace default → local. - * Works for a draft too (its id survives promotion). Not seen by the agent — a - * user-facing tool-execution target only. - */ - setComputer(computerId: string | null): Promise; - /** - * Every remote computer discovered from the user's `~/.ssh/config` - * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD - * (the user edits their ssh config to add one). Empty until the `ssh` - * extension lands. - */ - readonly computers: readonly ComputerEntry[]; - /** - * The live connection state of a computer (`GET /computers/:alias/status`): - * whether Dispatch currently holds an open SSH session to it. Returns null - * only if no alias is given (the focused conversation is local). Polled by the - * `ComputerField` while a computer is selected. - */ - computerStatus(alias: string): Promise; - /** - * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens - * an SSH connection to the alias, runs a trivial command, then closes. `ok` is - * true on success; `error` carries the failure reason otherwise. - */ - testComputer(alias: string): Promise; - /** - * The workspace conversation's persisted reasoning effort, or null when never - * set (the server then resolves turns at the default, `"high"`). - */ - readonly reasoningEffort: ReasoningEffort | null; - /** - * Persist the workspace conversation's reasoning effort - * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id - * survives promotion), so the first turn already runs at the chosen level. - * Takes effect from the NEXT turn; resolution stays server-owned. - */ - setReasoningEffort(level: ReasoningEffort): Promise; - /** - * Manually trigger conversation compaction (`POST /conversations/:id/compact`). - * Summarizes old messages + retains the most recent N. Returns null when no - * conversation is focused (a draft has nothing to compact). - */ - compactNow(keepLastN?: number): Promise; - /** - * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the - * turn without closing the conversation — partial messages are persisted, the - * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`. - * Returns null when no conversation is focused. - */ - stopGeneration(): void; - /** - * The workspace conversation's auto-compact percent (0-100). `0` = disabled - * (manual only); a positive number = auto-compact triggers when the last - * turn's input tokens exceed it. Seeded from the backend on focus change. - */ - readonly compactPercent: number | null; - /** - * Persist the workspace conversation's auto-compact percent - * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the - * trigger percentage of the model's context window. Default (null) is 85. - * number enables. Works for a draft too (its id survives promotion). - */ - setCompactPercent(percent: number): Promise; - /** - * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). - * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. - */ - lspStatus(): Promise; - /** - * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`). - * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers` - * when no cwd is set; the backend lazily connects servers, so this may take a - * moment on the first call for a cwd. - */ - mcpStatus(): Promise; - /** - * Load the global system prompt template (`GET /system-prompt`). The template is - * conversation-agnostic; it is resolved once per conversation on first turn and - * persisted for prompt-cache safety. - */ - loadSystemPrompt(): Promise; - /** - * Persist the global system prompt template (`PUT /system-prompt`). Changes apply - * to new conversations on their first turn; existing conversations keep their - * resolved system prompt until compaction. - */ - setSystemPrompt(template: string): Promise; - /** - * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`). - * Used by the builder to render the variable selector buttons. - */ - loadSystemPromptVariables(): Promise; - /** The persisted chat limit (max loaded chunks per conversation). */ - readonly chatLimit: number; - /** - * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or - * `undefined` when unknown. Drives the tab-bar generating indicator - * (cross-device: a tab spinning because another device's turn is running). - */ - conversationStatus(conversationId: string): ConversationStatus | undefined; - /** - * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to - * localStorage and propagates to every live chat store (trim if lower, - * deferred via the unload gate while a reader is scrolled up; no-op if - * higher — page unloaded history back in via "Show earlier"). Stores created - * afterwards pick the new limit up at creation. Always succeeds (FE-local). - */ - setChatLimit(limit: number): Promise; - /** - * Wire the chat-limit unload gate (composition-root injection, called once by - * the shell after it owns the scroll region): unloading old chunks is allowed - * only while the gate returns true — i.e. the reader is stuck to the bottom — - * so a trim never yanks content out from under someone reading history. - * Before attachment unloading is allowed (the initial view starts at the - * bottom). - */ - attachUnloadGate(gate: () => boolean): void; - /** - * A critical error that blocks normal operation (e.g. the cross-device tab - * restore fetch failed). When non-null, a full-screen modal is shown with the - * error details. Cleared by `clearFatalError` (the modal's dismiss button). - */ - readonly fatalError: string | null; - /** Dismiss the fatal error (called by the error modal's X button). */ - clearFatalError(): void; - dispose(): void; + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; + /** The workspace currently in view (URL slug); tabs are filtered to it. */ + readonly activeWorkspaceId: string; + readonly activeChat: ChatStore; + readonly models: readonly string[]; + /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ + readonly modelInfo: Readonly>; + readonly activeModel: string; + readonly catalog: ProtocolState["catalog"]; + /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ + readonly surfaces: readonly SurfaceSpec[]; + readonly lastError: ProtocolState["lastError"]; + /** The localStorage instance the store uses for persistence (tabs, chatLimit). + * Exposed so the shell can persist sidebar layout via the same adapter. */ + readonly storage: Storage | undefined; + /** The current spec for one surface by id (discovery-by-id), or null if absent. */ + surface(surfaceId: string): SurfaceSpec | null; + send(text: string): void; + /** + * Enqueue a steering message onto the focused conversation's queue + * (`chat.queue` WS op). While a turn is generating, the message is delivered + * mid-turn at the next tool-result boundary; when idle, the server + * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user + * wants to add input — the server owns the idle-vs-generating decision. + */ + queueMessage(text: string): void; + selectModel(model: string): void; + newDraft(): void; + /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ + setActiveWorkspace(workspaceId: string): void; + selectTab(conversationId: string): void; + closeTab(conversationId: string): void; + renameTab(conversationId: string, title: string): void; + invoke(surfaceId: string, actionId: string, payload?: unknown): void; + /** + * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). + * Returns null when no conversation is focused (a draft has nothing to warm). + */ + warmNow(): Promise; + /** The workspace conversation's persisted working directory, or null when unset. */ + readonly cwd: string | null; + /** The conversation workspace settings target: the active tab, or the pending draft's id. */ + readonly currentConversationId: string; + /** + * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). + * Works for a draft too (its id survives promotion), so the first turn runs in it. + */ + setCwd(cwd: string): Promise; + /** + * The workspace conversation's persisted computer (an SSH `Host` alias), or + * null when never set / local. Seeded from the backend on focus change. + */ + readonly computerId: string | null; + /** + * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`). + * Pass null to clear → the conversation inherits the workspace default → local. + * Works for a draft too (its id survives promotion). Not seen by the agent — a + * user-facing tool-execution target only. + */ + setComputer(computerId: string | null): Promise; + /** + * Every remote computer discovered from the user's `~/.ssh/config` + * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD + * (the user edits their ssh config to add one). Empty until the `ssh` + * extension lands. + */ + readonly computers: readonly ComputerEntry[]; + /** + * The live connection state of a computer (`GET /computers/:alias/status`): + * whether Dispatch currently holds an open SSH session to it. Returns null + * only if no alias is given (the focused conversation is local). Polled by the + * `ComputerField` while a computer is selected. + */ + computerStatus(alias: string): Promise; + /** + * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens + * an SSH connection to the alias, runs a trivial command, then closes. `ok` is + * true on success; `error` carries the failure reason otherwise. + */ + testComputer(alias: string): Promise; + /** + * The workspace conversation's persisted reasoning effort, or null when never + * set (the server then resolves turns at the default, `"high"`). + */ + readonly reasoningEffort: ReasoningEffort | null; + /** + * Persist the workspace conversation's reasoning effort + * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id + * survives promotion), so the first turn already runs at the chosen level. + * Takes effect from the NEXT turn; resolution stays server-owned. + */ + setReasoningEffort(level: ReasoningEffort): Promise; + /** + * Manually trigger conversation compaction (`POST /conversations/:id/compact`). + * Summarizes old messages + retains the most recent N. Returns null when no + * conversation is focused (a draft has nothing to compact). + */ + compactNow(keepLastN?: number): Promise; + /** + * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the + * turn without closing the conversation — partial messages are persisted, the + * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`. + * Returns null when no conversation is focused. + */ + stopGeneration(): void; + /** + * The workspace conversation's auto-compact percent (0-100). `0` = disabled + * (manual only); a positive number = auto-compact triggers when the last + * turn's input tokens exceed it. Seeded from the backend on focus change. + */ + readonly compactPercent: number | null; + /** + * Persist the workspace conversation's auto-compact percent + * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the + * trigger percentage of the model's context window. Default (null) is 85. + * number enables. Works for a draft too (its id survives promotion). + */ + setCompactPercent(percent: number): Promise; + /** + * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). + * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. + */ + lspStatus(): Promise; + /** + * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`). + * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers` + * when no cwd is set; the backend lazily connects servers, so this may take a + * moment on the first call for a cwd. + */ + mcpStatus(): Promise; + /** + * Load the global system prompt template (`GET /system-prompt`). The template is + * conversation-agnostic; it is resolved once per conversation on first turn and + * persisted for prompt-cache safety. + */ + loadSystemPrompt(): Promise; + /** + * Persist the global system prompt template (`PUT /system-prompt`). Changes apply + * to new conversations on their first turn; existing conversations keep their + * resolved system prompt until compaction. + */ + setSystemPrompt(template: string): Promise; + /** + * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`). + * Used by the builder to render the variable selector buttons. + */ + loadSystemPromptVariables(): Promise; + /** The persisted chat limit (max loaded chunks per conversation). */ + readonly chatLimit: number; + /** + * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or + * `undefined` when unknown. Drives the tab-bar generating indicator + * (cross-device: a tab spinning because another device's turn is running). + */ + conversationStatus(conversationId: string): ConversationStatus | undefined; + /** + * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to + * localStorage and propagates to every live chat store (trim if lower, + * deferred via the unload gate while a reader is scrolled up; no-op if + * higher — page unloaded history back in via "Show earlier"). Stores created + * afterwards pick the new limit up at creation. Always succeeds (FE-local). + */ + setChatLimit(limit: number): Promise; + /** + * Wire the chat-limit unload gate (composition-root injection, called once by + * the shell after it owns the scroll region): unloading old chunks is allowed + * only while the gate returns true — i.e. the reader is stuck to the bottom — + * so a trim never yanks content out from under someone reading history. + * Before attachment unloading is allowed (the initial view starts at the + * bottom). + */ + attachUnloadGate(gate: () => boolean): void; + /** + * A critical error that blocks normal operation (e.g. the cross-device tab + * restore fetch failed). When non-null, a full-screen modal is shown with the + * error details. Cleared by `clearFatalError` (the modal's dismiss button). + */ + readonly fatalError: string | null; + /** Dismiss the fatal error (called by the error modal's X button). */ + clearFatalError(): void; + dispose(): void; } export interface CreateAppStoreOptions { - url?: string; - httpUrl?: string; - socketFactory?: (url: string) => WebSocketLike; - fetchImpl?: typeof fetch; - indexedDB?: IDBFactory; - conversationId?: string; - localStorage?: Storage; - /** The workspace to scope to at boot (its URL slug); "default" if absent. */ - workspaceId?: string; + url?: string; + httpUrl?: string; + socketFactory?: (url: string) => WebSocketLike; + fetchImpl?: typeof fetch; + indexedDB?: IDBFactory; + conversationId?: string; + localStorage?: Storage; + /** The workspace to scope to at boot (its URL slug); "default" if absent. */ + workspaceId?: string; } function createHistorySync(httpBase: string, fetchImpl: typeof fetch): HistorySync { - return async (conversationId, sinceSeq, window) => { - let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`; - // CR-5 windowing (transport-contract@0.10.0): both must be positive - // integers when present (the server 400s otherwise; callers guarantee it). - if (window?.limit !== undefined) url += `&limit=${window.limit}`; - if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; - const res = await fetchImpl(url); - if (!res.ok) { - throw new Error(`History sync failed: ${res.status}`); - } - return (await res.json()) as ConversationHistoryResponse; - }; + return async (conversationId, sinceSeq, window) => { + let url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`; + // CR-5 windowing (transport-contract@0.10.0): both must be positive + // integers when present (the server 400s otherwise; callers guarantee it). + if (window?.limit !== undefined) url += `&limit=${window.limit}`; + if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`; + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`History sync failed: ${res.status}`); + } + return (await res.json()) as ConversationHistoryResponse; + }; } function createMetricsSync(httpBase: string, fetchImpl: typeof fetch): MetricsSync { - return async (conversationId: string) => { - const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`; - const res = await fetchImpl(url); - if (!res.ok) return { turns: [] }; - return (await res.json()) as ConversationMetricsResponse; - }; + return async (conversationId: string) => { + const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}/metrics`; + const res = await fetchImpl(url); + if (!res.ok) return { turns: [] }; + return (await res.json()) as ConversationMetricsResponse; + }; } export function createAppStore(opts?: CreateAppStoreOptions): AppStore { - let protocol = $state(protocolInitialState()); - let models = $state([]); - let modelInfo = $state>>({}); - // Discovered SSH computers (`GET /computers`). Global (like `models`); empty - // until the `ssh` extension lands. Read-only — no CRUD (the user edits their - // `~/.ssh/config`). - let computers = $state([]); - let activeModel = $state(DEFAULT_MODEL); - let fatalError = $state(null); - - // The workspace currently in view (its URL slug); "default" until routing - // sets it. Tabs are filtered to this workspace; a new conversation is stamped - // with it on `chat.send`. - let activeWorkspaceId = $state(opts?.workspaceId ?? "default"); - - const wsLocation = typeof location !== "undefined" ? location : undefined; - const wsUrl = - opts?.url ?? - resolveWsUrl( - { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, - wsLocation, - ); - - const httpLocation = typeof location !== "undefined" ? location : undefined; - const httpBase = - opts?.httpUrl ?? - resolveHttpUrl( - { - VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, - VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, - }, - httpLocation, - ); - - const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); - const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; - const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; - - const storageAdapter = createLocalStore("dispatch.tabs", { - storage: localStorageOpt, - }); - const tabsStore: TabsStore = createTabsStore(storageAdapter); - - // The chat limit (max loaded chunks per conversation) — a persisted local - // setting surfaced in the sidebar's Settings view. Reactive so the field + - // any live-apply re-trim update together. The default is written back on - // first run so the knob is discoverable in localStorage too. - const chatLimitStore = createLocalStore("dispatch.chatLimit", { - storage: localStorageOpt, - }); - const storedChatLimit = chatLimitStore.load(); - const normalizedChatLimit = normalizeChatLimit(storedChatLimit); - let chatLimit = $state(normalizedChatLimit); - if (storedChatLimit === null) { - chatLimitStore.save(normalizedChatLimit); - } - - // Unload gate — attached by the shell once it owns the scroll region (see - // `AppStore.attachUnloadGate`). Until then, unloading is allowed. - let unloadGate: (() => boolean) | null = null; - - const cache: ConversationCache = createConversationCache( - createIdbChunkStore({ indexedDB: indexedDBFactory }), - ); - - const historySync = createHistorySync(httpBase, fetchImpl); - const metricsSync = createMetricsSync(httpBase, fetchImpl); - - const chatStores = new Map(); - - function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { - return createChatStore({ - conversationId, - model, - workspaceId, - transport: { - send(msg) { - socket?.send(msg); - }, - }, - historySync, - metricsSync, - cache, - // Read from the persisted store (kept in sync with the reactive `chatLimit` - // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state` - // — each store captures its limit at creation; live updates go through - // `setChatLimit`. - chatLimit: normalizeChatLimit(chatLimitStore.load()), - canUnload: () => (unloadGate === null ? true : unloadGate()), - onError: (context, err) => { - reportError(`${context} (conversation: ${conversationId})`, err); - }, - }); - } - - const initialDraftId = randomId(); - // Read `activeWorkspaceId` with untrack to suppress Svelte's - // `state_referenced_locally` warning — this intentionally captures the - // INITIAL workspace for the boot draft. When the workspace changes later, - // `setActiveWorkspace` creates a fresh draft store with the new id. - let draftStore: ChatStore = createChatFor( - initialDraftId, - DEFAULT_MODEL, - untrack(() => activeWorkspaceId), - ); - let draftConversationId: string = initialDraftId; - - let activeChat = $state(draftStore as ChatStore); - - // The active conversation's persisted working directory (per-tab). Seeded from - // the backend on focus change; null for a draft / when unset. - let cwd = $state(null); - - /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ - async function refreshCwd(): Promise { - const id = workspaceConversationId(); - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); - if (!res.ok) return; - const data = (await res.json()) as CwdResponse; - // Guard a slow response losing a race with a conversation switch. - if (workspaceConversationId() === id) cwd = data.cwd ?? null; - } catch (err) { - reportError("Failed to load working directory", err); - } - } - - // The active conversation's persisted computer (SSH Host alias). Seeded on - // focus change; null = local / never set (inherits the workspace default). - let computerId = $state(null); - - /** - * Refetch the workspace conversation's persisted computer into reactive state - * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false - * so it is a silent no-op (mirrors `refreshCwd` for a draft). - */ - async function refreshComputer(): Promise { - const id = workspaceConversationId(); - // Clear immediately so a switch never shows the PREVIOUS conversation's - // computer while the fetch is in flight (null renders as "Local"). - computerId = null; - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`); - if (!res.ok) return; - const data = (await res.json()) as ConversationComputerResponse; - // Guard a slow response losing a race with a conversation switch. - if (workspaceConversationId() === id) computerId = data.computerId ?? null; - } catch (err) { - reportError("Failed to load computer", err); - } - } - - /** Refetch the workspace conversation's persisted model (works for a draft too). */ - async function refreshModel(): Promise { - const id = workspaceConversationId(); - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/model`); - if (!res.ok) return; - const data = (await res.json()) as ModelResponse; - if (workspaceConversationId() !== id) return; - if (typeof data.model === "string" && data.model.length > 0) { - activeModel = data.model; - const activeId = tabsStore.activeConversationId; - if (activeId !== null) { - tabsStore.setModel(activeId, data.model); - chatStores.get(activeId)?.setModel(data.model); - } else { - draftStore.setModel(data.model); - } - } - } catch (err) { - reportError("Failed to load model", err); - } - } - - // The workspace conversation's persisted reasoning effort. Seeded from the - // backend on focus change; null = never set (the server default applies). - let reasoningEffort = $state(null); - - /** Refetch the workspace conversation's reasoning effort (works for a draft too). */ - async function refreshReasoningEffort(): Promise { - const id = workspaceConversationId(); - // Clear immediately so a switch never shows the PREVIOUS conversation's level - // while the fetch is in flight (null renders as the server default). - reasoningEffort = null; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, - ); - if (!res.ok) return; - const data = (await res.json()) as ReasoningEffortResponse; - // Guard a slow response losing a race with a conversation switch. - if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; - } catch (err) { - reportError("Failed to load reasoning effort", err); - } - } - - // The workspace conversation's auto-compact percent. Seeded from the - // backend on focus change; null = not yet fetched. 0 = disabled. - let compactPercent = $state(null); - - /** Refetch the workspace conversation's compact percent (works for a draft too). */ - async function refreshCompactPercent(): Promise { - const id = workspaceConversationId(); - compactPercent = null; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, - ); - if (!res.ok) return; - const data = (await res.json()) as CompactPercentResponse; - if (workspaceConversationId() === id) compactPercent = data.threshold; - } catch (err) { - reportError("Failed to load compact percent", err); - } - } - - function getActiveChat(): ChatStore { - const activeId = tabsStore.activeConversationId; - if (activeId === null) { - return draftStore; - } - return chatStores.get(activeId) ?? draftStore; - } - - function refreshActiveChat(): void { - activeChat = getActiveChat(); - } - - function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void { - let targetId: string | undefined; - if (msg.type === "chat.delta") { - targetId = msg.event.conversationId; - } else { - targetId = msg.conversationId; - } - - if (targetId !== undefined) { - const store = chatStores.get(targetId); - if (store !== undefined) { - store.handleDelta(msg); - return; - } - } - - // fallback: try all stores (chat.error without conversationId) - for (const store of chatStores.values()) { - store.handleDelta(msg); - } - } - - /** - * Start watching a conversation's live turn events (`chat.subscribe`). Sent for - * EVERY open conversation — not just the active one — so a backgrounded tab keeps - * streaming a running turn, and a reloaded/second client re-attaches to an - * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; - * the socket queues it until the connection is open. NOT needed right after - * `chat.send` (that auto-subscribes the sending connection). - */ - function subscribeChat(conversationId: string): void { - socket?.send({ type: "chat.subscribe", conversationId }); - } - - /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ - function unsubscribeChat(conversationId: string): void { - socket?.send({ type: "chat.unsubscribe", conversationId }); - } - - /** - * Tell the backend the user EXPLICITLY closed this conversation's tab - * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with - * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). - * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave - * both running. Fire-and-forget: a failure is non-fatal (worst case the - * warming keeps running until a later close/toggle), and the endpoint is - * idempotent server-side. - */ - function closeConversation(conversationId: string): void { - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { - method: "POST", - }).catch((err) => { - reportError("Failed to close conversation", err); - }); - } - - /** The conversation the surfaces should scope to (undefined for a draft). */ - function focusedConversationId(): string | undefined { - return tabsStore.activeConversationId ?? undefined; - } - - /** - * The conversation id workspace settings (cwd / LSP) target: the active tab, or - * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this - * is NEVER undefined — the draft has a stable client-minted id that survives - * promotion (first send), so a cwd set on a draft carries into the real turn. - */ - function workspaceConversationId(): string { - return tabsStore.activeConversationId ?? draftConversationId; - } - - function handleServerMessage(msg: SurfaceServerMessage): void { - protocol = applyServerMessage(protocol, msg); - // Surfaces are auto-expanded: whenever the catalog changes, subscribe to - // every entry (and drop subscriptions for entries that vanished). - if (msg.type === "catalog") { - syncSubscriptions(); - } - } - - /** - * Subscribe to every catalog entry, scoped to the focused conversation, and - * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped - * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` - * emits unsubscribe-old + subscribe-new); a global surface ignores the id. - */ - function syncSubscriptions(): void { - const cid = focusedConversationId(); - for (const entry of protocol.catalog) { - // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id - // so a conversation switch doesn't churn a redundant unsubscribe+subscribe - // round trip (ui-contract@0.2.0 catalog `scope`; ABSENT = assume - // conversation-scoped, the conservative pre-0.2.0 policy). - const scoped = entry.scope === "global" ? undefined : cid; - const result = protocolSubscribe(protocol, entry.id, scoped); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } - } - const catalogIds = new Set(protocol.catalog.map((e) => e.id)); - for (const id of [...protocol.subscriptions.keys()]) { - if (!catalogIds.has(id)) { - const result = protocolUnsubscribe(protocol, id); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } - } - } - } - - let socket: ReturnType | null = null; - - /** - * Open a conversation tab — used by the `conversation.open` WS broadcast - * (CLI `--open` flag) and by `conversation.statusChanged` when a new active - * conversation is discovered. If the conversation is already open, this is a - * no-op; otherwise create a chat store, load its history, subscribe to its live - * turns, and add the tab WITHOUT switching the active conversation (the user - * stays on their current tab; the new tab appears in the strip). The tab is - * stamped with the conversation's actual `workspaceId`, NOT the viewer's - * currently active workspace. - */ - function openConversation(conversationId: string, workspaceId: string): void { - if (chatStores.has(conversationId)) return; - const store = createChatFor(conversationId, activeModel, workspaceId); - chatStores.set(conversationId, store); - void store.load(); - subscribeChat(conversationId); - tabsStore.openTab({ - conversationId, - model: activeModel, - title: "Conversation", - workspaceId, - }); - } - - /** - * Remove a tab + its chat store locally (NO `POST /close` — used when the - * backend already marked the conversation `closed` via `conversation.statusChanged`). - */ - function removeTabLocally(conversationId: string): void { - unsubscribeChat(conversationId); - const store = chatStores.get(conversationId); - if (store !== undefined) { - store.dispose(); - chatStores.delete(conversationId); - } - void cache.delete(conversationId); - tabsStore.closeTab(conversationId); - conversationStatuses.delete(conversationId); - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - } - - /** - * Surface a swallowed error to the user via the full-screen error modal - * (`fatalError` → `ErrorModal`). Logs to `console.error` too so the stack is - * in devtools. Called from catch blocks that previously swallowed errors silently. - */ - function reportError(context: string, err: unknown): void { - console.error(`[reportError] ${context}`, err); - const detail = - err instanceof Error - ? `${err.name}: ${err.message}\n\n${err.stack ?? "(no stack trace available)"}` - : String(err); - fatalError = `${context}\n\n${detail}`; - } - - // Conversation lifecycle status (backend-owned, pushed via WS + - // fetched on connect). Keyed by conversationId. - let conversationStatuses = $state>(new Map()); - - /** - * Fetch `GET /conversations?status=active,idle` on connect to restore the - * tab bar across devices. Merges: opens tabs for conversations not already - * open, removes tabs for conversations that are no longer active/idle - * (closed on another device), and subscribes to `active` conversations' - * live streams. - */ - async function fetchOpenConversations(): Promise { - try { - const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); - if (!res.ok) return; - const data = (await res.json()) as ConversationListResponse; - - // Update the status map from the authoritative backend list. - const newStatuses = new Map(); - for (const conv of data.conversations) { - newStatuses.set(conv.id, conv.status); - } - conversationStatuses = newStatuses; - - // Open tabs for conversations not already open. - const existingIds = new Set(chatStores.keys()); - for (const conv of data.conversations) { - if (!existingIds.has(conv.id)) { - const store = createChatFor(conv.id, activeModel, conv.workspaceId); - chatStores.set(conv.id, store); - void store.load(); - subscribeChat(conv.id); - tabsStore.openTab({ - conversationId: conv.id, - model: activeModel, - title: conv.title, - workspaceId: conv.workspaceId, - }); - } else { - // Already open — update the title from the backend if it differs. - tabsStore.setTitle(conv.id, conv.title); - } - } - - // Remove tabs for conversations no longer active/idle (closed elsewhere). - const backendIds = new Set(data.conversations.map((c) => c.id)); - for (const tab of tabsStore.tabs) { - if (!backendIds.has(tab.conversationId)) { - removeTabLocally(tab.conversationId); - } - } - } catch (err) { - reportError( - `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, - err, - ); - } - } - - const socketOpts: SurfaceSocketOptions = { - url: wsUrl, - onMessage: handleServerMessage, - onChat: handleChatMessage, - onConversationOpen(msg: ConversationOpenMessage): void { - openConversation(msg.conversationId, msg.workspaceId); - }, - onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { - const { conversationId, status, workspaceId } = msg; - if (status === "closed") { - // Closed on another device (or the backend) — remove the tab locally. - if (chatStores.has(conversationId)) { - removeTabLocally(conversationId); - } - return; - } - // active / idle — update the status map (drives the tab spinner). - conversationStatuses = new Map(conversationStatuses).set(conversationId, status); - // If this is a new active conversation we don't have a tab for, open one. - if (status === "active" && !chatStores.has(conversationId)) { - openConversation(conversationId, workspaceId); - } - }, - onConversationCompacted(msg: ConversationCompactedMessage): void { - // Compaction keeps the conversation ID — the old full history is forked - // to an archive (newConversationId). Just reload the same conversation's - // history (dispose stale store + cache + re-fetch). - const cid = msg.conversationId; - const wasActive = tabsStore.activeConversationId === cid; - const store = chatStores.get(cid); - if (store !== undefined) { - store.dispose(); - } - void cache.delete(cid); - const fresh = createChatFor(cid, activeModel, activeWorkspaceId); - chatStores.set(cid, fresh); - void fresh.load(); - if (wasActive) { - refreshActiveChat(); - } - }, - onReopen() { - // The server forgot our subscriptions on reconnect; re-send each with the - // conversation it was subscribed under (protocolSubscribe would no-op since - // they're still in our local map, so emit the wire messages directly). - for (const [surfaceId, sub] of protocol.subscriptions) { - const msg: SubscribeMessage = - sub.conversationId === undefined - ? { type: "subscribe", surfaceId } - : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; - socket?.send(msg); - } - // Re-attach to every open conversation's turn stream. A turn that kept - // running while we were disconnected resumes streaming (server replays it - // from `turn-start`); one that sealed while we were gone is committed from - // history by `resync()` (which also clears a now-stale "generating"). - for (const tab of tabsStore.tabs) { - subscribeChat(tab.conversationId); - chatStores.get(tab.conversationId)?.resync(); - } - }, - }; - if (opts?.socketFactory !== undefined) { - socketOpts.socketFactory = opts.socketFactory; - } - socket = createSurfaceSocket(socketOpts); - - // Fetch model catalog - void fetchImpl(`${httpBase}/models`) - .then((res) => { - if (!res.ok) return; - return res.json() as Promise; - }) - .then((data) => { - if (data === undefined) return; - models = data.models; - modelInfo = data.modelInfo ?? {}; - if (data.models.length > 0 && !data.models.includes(activeModel)) { - const first = data.models[0]; - if (first !== undefined) { - activeModel = first; - draftStore.setModel(first); - } - } - }) - .catch((err) => { - reportError("Failed to load model list", err); - }); - - // Fetch the discovered-computer catalog (global, like models). Empty until - // the `ssh` extension lands — a safe no-op until then (the selector shows - // "Local (none)" only). Non-fatal: a failure leaves an empty list. - void fetchImpl(`${httpBase}/computers`) - .then((res) => { - if (!res.ok) return { computers: [] } as ComputerListResponse; - return res.json() as Promise; - }) - .then((data) => { - computers = data?.computers ?? []; - }) - .catch((err) => { - reportError("Failed to load computer list", err); - }); - - // Restore persisted tabs - const persistedState = storageAdapter.load(); - if (persistedState !== null && persistedState.tabs.length > 0) { - for (const tab of persistedState.tabs) { - const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId); - chatStores.set(tab.conversationId, store); - void store.load(); - // Watch each restored conversation's live turns: after a reload mid-turn the - // server replays the in-flight turn so we keep rendering it. Queued until the - // socket opens. - subscribeChat(tab.conversationId); - } - if (persistedState.activeConversationId !== null) { - const activeTab = persistedState.tabs.find( - (t) => t.conversationId === persistedState.activeConversationId, - ); - if (activeTab !== undefined) { - activeModel = activeTab.model; - } - } - } - - refreshActiveChat(); - void refreshCwd(); - void refreshComputer(); - void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - - // Fetch the authoritative open-conversation list from the backend (cross- - // device tab sync). Merges with the localStorage-restored tabs: opens new - // ones, removes closed ones, updates titles + statuses. - void fetchOpenConversations(); - - return { - get tabs(): readonly Tab[] { - return tabsStore.tabs.filter((t) => t.workspaceId === activeWorkspaceId); - }, - get activeConversationId(): string | null { - return tabsStore.activeConversationId; - }, - get activeWorkspaceId(): string { - return activeWorkspaceId; - }, - setActiveWorkspace(workspaceId: string): void { - activeWorkspaceId = workspaceId; - // Reset to a fresh draft scoped to the new workspace so a new chat is - // stamped with the right `workspaceId` on `chat.send`. - const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel, workspaceId); - draftConversationId = nextDraftId; - tabsStore.newDraft(); - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); - void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - }, - get activeChat(): ChatStore { - return activeChat; - }, - get models(): readonly string[] { - return models; - }, - get modelInfo(): Readonly> { - return modelInfo; - }, - get activeModel(): string { - return activeModel; - }, - get catalog() { - return protocol.catalog; - }, - get surfaces(): readonly SurfaceSpec[] { - const out: SurfaceSpec[] = []; - for (const entry of protocol.catalog) { - const spec = getSurfaceSpec(protocol, entry.id); - if (spec) out.push(spec); - } - return out; - }, - get lastError() { - return protocol.lastError; - }, - get storage() { - return localStorageOpt; - }, - get cwd(): string | null { - return cwd; - }, - get computerId(): string | null { - return computerId; - }, - get computers(): readonly ComputerEntry[] { - return computers; - }, - get reasoningEffort(): ReasoningEffort | null { - return reasoningEffort; - }, - get compactPercent(): number | null { - return compactPercent; - }, - get chatLimit(): number { - return chatLimit; - }, - conversationStatus(conversationId: string): ConversationStatus | undefined { - return conversationStatuses.get(conversationId); - }, - get currentConversationId(): string { - return workspaceConversationId(); - }, - - surface(surfaceId: string): SurfaceSpec | null { - return getSurfaceSpec(protocol, surfaceId); - }, - - send(text: string): void { - if (tabsStore.activeConversationId === null) { - // Draft: promote to tab on first send - const conversationId = draftConversationId; - const model = activeModel; - tabsStore.createTab({ - conversationId, - model, - title: deriveTitle(text), - workspaceId: activeWorkspaceId, - }); - chatStores.set(conversationId, draftStore); - void draftStore.load(); - - // Prepare next draft - const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); - draftConversationId = nextDraftId; - - refreshActiveChat(); - // The draft became a real conversation: re-scope conversation-scoped - // surfaces (e.g. cache-warming) to its id. - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - // Now send on the promoted store - chatStores.get(conversationId)?.send(text); - } else { - activeChat.send(text); - } - }, - - queueMessage(text: string): void { - // Only offered while generating (Composer switches to `chat.queue` - // when `status === "running"`), so a draft (never generating) never - // reaches here. `chat.queue` auto-starts a turn if idle, so even a race - // (turn sealed between the status read and the send) is safe — the - // server starts a fresh turn with the message as its opening prompt. - activeChat.queueMessage(text); - }, - - selectModel(model: string): void { - activeModel = model; - const activeId = tabsStore.activeConversationId; - if (activeId !== null) { - tabsStore.setModel(activeId, model); - chatStores.get(activeId)?.setModel(model); - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model } satisfies SetModelRequest), - }).catch((err) => { - reportError("Failed to persist model", err); - }); - } else { - draftStore.setModel(model); - } - }, - - newDraft(): void { - tabsStore.newDraft(); - const nextDraftId = randomId(); - draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); - draftConversationId = nextDraftId; - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); - void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - }, - - selectTab(conversationId: string): void { - tabsStore.selectTab(conversationId); - const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); - if (tab !== undefined) { - activeModel = tab.model; - } - refreshActiveChat(); - syncSubscriptions(); - void refreshCwd(); - void refreshComputer(); - void refreshModel(); - void refreshReasoningEffort(); - void refreshCompactPercent(); - }, - - closeTab(conversationId: string): void { - // The user is DONE with this chat: abort any in-flight turn + stop/disable - // its cache-warming, server-side (POST /close sets status → "closed"). - closeConversation(conversationId); - removeTabLocally(conversationId); - }, - - renameTab(conversationId: string, title: string): void { - tabsStore.setTitle(conversationId, title); - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title } satisfies SetTitleRequest), - }).catch((err) => { - reportError("Failed to rename conversation", err); - }); - }, - - invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke( - protocol, - surfaceId, - actionId, - payload, - focusedConversationId(), - ); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } - }, - - async warmNow(): Promise { - const conversationId = tabsStore.activeConversationId; - if (conversationId === null) return null; - const body: WarmRequest = { conversationId, model: activeModel }; - try { - const res = await fetchImpl(`${httpBase}/chat/warm`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; - } - return { ok: true, response: (await res.json()) as WarmResponse }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; - } - }, - - async setCwd(value: string): Promise { - const id = workspaceConversationId(); - const body: SetCwdRequest = { - cwd: value, - workspaceId: untrack(() => activeWorkspaceId), - }; - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; - } - const data = (await res.json()) as CwdResponse; - const next = data.cwd ?? null; - if (workspaceConversationId() === id) cwd = next; - return { ok: true, cwd: next }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; - } - }, - - async setComputer(computerIdValue: string | null): Promise { - const id = workspaceConversationId(); - const body: SetConversationComputerRequest = { computerId: computerIdValue }; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/computer`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as ConversationComputerResponse; - const next = data.computerId ?? null; - if (workspaceConversationId() === id) computerId = next; - return { ok: true, computerId: next }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set computer request failed", - }; - } - }, - - async computerStatus(alias: string): Promise { - if (alias === "") return null; - try { - const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`, - }; - } - const status = (await res.json()) as ComputerStatusResponse; - return { ok: true, response: status }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Computer status request failed", - }; - } - }, - - async testComputer(alias: string): Promise { - if (alias === "") return null; - try { - const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, { - method: "POST", - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`, - }; - } - const response = (await res.json()) as TestComputerResponse; - return { ok: true, response }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Test computer request failed", - }; - } - }, - - async setReasoningEffort(level: ReasoningEffort): Promise { - const id = workspaceConversationId(); - const body: SetReasoningEffortRequest = { reasoningEffort: level }; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as ReasoningEffortResponse; - const next = data.reasoningEffort ?? level; - if (workspaceConversationId() === id) reasoningEffort = next; - return { ok: true, reasoningEffort: next }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set reasoning effort request failed", - }; - } - }, - - stopGeneration(): void { - const conversationId = tabsStore.activeConversationId; - if (conversationId === null) return; - void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { - method: "POST", - }).catch((err) => { - reportError("Failed to stop generation", err); - }); - }, - - async compactNow(keepLastN?: number): Promise { - const conversationId = tabsStore.activeConversationId; - if (conversationId === null) return null; - const body: Record = {}; - if (keepLastN !== undefined) body.keepLastN = keepLastN; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Compact failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as CompactResponse; - return { ok: true, response: data }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Compact request failed", - }; - } - }, - - async setCompactPercent(percent: number): Promise { - const id = workspaceConversationId(); - const body: SetCompactPercentRequest = { threshold: percent }; - try { - const res = await fetchImpl( - `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as CompactPercentResponse; - if (workspaceConversationId() === id) compactPercent = data.threshold; - return { ok: true, percent: data.threshold }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set compact percent request failed", - }; - } - }, - - async setChatLimit(limit: number): Promise { - const next = normalizeChatLimit(limit); - chatLimitStore.save(next); - chatLimit = next; - // Propagate to every live chat store. The ACTIVE one is awaited so its - // refill (on a raise) lands before the caller returns — letting the - // shell preserve scroll over the prepended older chunks. Background - // stores refill fire-and-forget. Future stores pick the new limit up at - // creation (via the persisted store). - const active = getActiveChat(); - await active.setChatLimit(next); - for (const s of chatStores.values()) { - if (s !== active) void s.setChatLimit(next); - } - if (draftStore !== active) void draftStore.setChatLimit(next); - return { ok: true, chatLimit: next }; - }, - - async lspStatus(): Promise { - const id = workspaceConversationId(); - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; - } - // Normalize the untyped body at this network seam so a malformed/partial - // response can never crash the renderer (servers is guaranteed an array). - const data = (await res.json()) as Partial; - const response: LspStatusResponse = { - conversationId: data.conversationId ?? id, - cwd: data.cwd ?? null, - servers: Array.isArray(data.servers) ? data.servers : [], - }; - return { ok: true, response }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "LSP status request failed", - }; - } - }, - - async mcpStatus(): Promise { - const id = workspaceConversationId(); - try { - const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` }; - } - // Normalize the untyped body at this network seam so a malformed/partial - // response can never crash the renderer (servers is guaranteed an array). - const data = (await res.json()) as Partial; - const response: McpStatusResponse = { - conversationId: data.conversationId ?? id, - cwd: data.cwd ?? null, - servers: Array.isArray(data.servers) ? data.servers : [], - }; - return { ok: true, response }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "MCP status request failed", - }; - } - }, - - async loadSystemPrompt(): Promise { - try { - const res = await fetchImpl(`${httpBase}/system-prompt`); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as SystemPromptTemplateResponse; - return { ok: true, template: data.template ?? "" }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Load system prompt request failed", - }; - } - }, - - async setSystemPrompt(template: string): Promise { - try { - const body: SetSystemPromptTemplateRequest = { template }; - const res = await fetchImpl(`${httpBase}/system-prompt`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as SystemPromptTemplateResponse; - return { ok: true, template: data.template }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set system prompt request failed", - }; - } - }, - - async loadSystemPromptVariables(): Promise { - try { - const res = await fetchImpl(`${httpBase}/system-prompt/variables`); - if (!res.ok) { - const errBody = (await res.json().catch(() => null)) as { error?: string } | null; - return { - ok: false, - error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`, - }; - } - const data = (await res.json()) as Partial; - return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Load system prompt variables request failed", - }; - } - }, - - attachUnloadGate(gate: () => boolean): void { - unloadGate = gate; - }, - - get fatalError(): string | null { - return fatalError; - }, - clearFatalError(): void { - fatalError = null; - }, - - dispose(): void { - for (const store of chatStores.values()) { - store.dispose(); - } - chatStores.clear(); - draftStore.dispose(); - socket?.close(); - socket = null; - }, - }; + let protocol = $state(protocolInitialState()); + let models = $state([]); + let modelInfo = $state>>({}); + // Discovered SSH computers (`GET /computers`). Global (like `models`); empty + // until the `ssh` extension lands. Read-only — no CRUD (the user edits their + // `~/.ssh/config`). + let computers = $state([]); + let activeModel = $state(DEFAULT_MODEL); + let fatalError = $state(null); + + // The workspace currently in view (its URL slug); "default" until routing + // sets it. Tabs are filtered to this workspace; a new conversation is stamped + // with it on `chat.send`. + let activeWorkspaceId = $state(opts?.workspaceId ?? "default"); + + const wsLocation = typeof location !== "undefined" ? location : undefined; + const wsUrl = + opts?.url ?? + resolveWsUrl( + { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, + wsLocation, + ); + + const httpLocation = typeof location !== "undefined" ? location : undefined; + const httpBase = + opts?.httpUrl ?? + resolveHttpUrl( + { + VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, + VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, + }, + httpLocation, + ); + + const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); + const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; + const localStorageOpt = opts?.localStorage ?? globalThis.localStorage; + + const storageAdapter = createLocalStore("dispatch.tabs", { + storage: localStorageOpt, + }); + const tabsStore: TabsStore = createTabsStore(storageAdapter); + + // The chat limit (max loaded chunks per conversation) — a persisted local + // setting surfaced in the sidebar's Settings view. Reactive so the field + + // any live-apply re-trim update together. The default is written back on + // first run so the knob is discoverable in localStorage too. + const chatLimitStore = createLocalStore("dispatch.chatLimit", { + storage: localStorageOpt, + }); + const storedChatLimit = chatLimitStore.load(); + const normalizedChatLimit = normalizeChatLimit(storedChatLimit); + let chatLimit = $state(normalizedChatLimit); + if (storedChatLimit === null) { + chatLimitStore.save(normalizedChatLimit); + } + + // Unload gate — attached by the shell once it owns the scroll region (see + // `AppStore.attachUnloadGate`). Until then, unloading is allowed. + let unloadGate: (() => boolean) | null = null; + + const cache: ConversationCache = createConversationCache( + createIdbChunkStore({ indexedDB: indexedDBFactory }), + ); + + const historySync = createHistorySync(httpBase, fetchImpl); + const metricsSync = createMetricsSync(httpBase, fetchImpl); + + const chatStores = new Map(); + + function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore { + return createChatStore({ + conversationId, + model, + workspaceId, + transport: { + send(msg) { + socket?.send(msg); + }, + }, + historySync, + metricsSync, + cache, + // Read from the persisted store (kept in sync with the reactive `chatLimit` + // by `setChatLimit` + boot) so this snapshot doesn't reference the `$state` + // — each store captures its limit at creation; live updates go through + // `setChatLimit`. + chatLimit: normalizeChatLimit(chatLimitStore.load()), + canUnload: () => (unloadGate === null ? true : unloadGate()), + onError: (context, err) => { + reportError(`${context} (conversation: ${conversationId})`, err); + }, + }); + } + + const initialDraftId = randomId(); + // Read `activeWorkspaceId` with untrack to suppress Svelte's + // `state_referenced_locally` warning — this intentionally captures the + // INITIAL workspace for the boot draft. When the workspace changes later, + // `setActiveWorkspace` creates a fresh draft store with the new id. + let draftStore: ChatStore = createChatFor( + initialDraftId, + DEFAULT_MODEL, + untrack(() => activeWorkspaceId), + ); + let draftConversationId: string = initialDraftId; + + let activeChat = $state(draftStore as ChatStore); + + // The active conversation's persisted working directory (per-tab). Seeded from + // the backend on focus change; null for a draft / when unset. + let cwd = $state(null); + + /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */ + async function refreshCwd(): Promise { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`); + if (!res.ok) return; + const data = (await res.json()) as CwdResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) cwd = data.cwd ?? null; + } catch (err) { + reportError("Failed to load working directory", err); + } + } + + // The active conversation's persisted computer (SSH Host alias). Seeded on + // focus change; null = local / never set (inherits the workspace default). + let computerId = $state(null); + + /** + * Refetch the workspace conversation's persisted computer into reactive state + * (works for a draft too). A draft's id 404s until promoted; `res.ok` is false + * so it is a silent no-op (mirrors `refreshCwd` for a draft). + */ + async function refreshComputer(): Promise { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's + // computer while the fetch is in flight (null renders as "Local"). + computerId = null; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/computer`); + if (!res.ok) return; + const data = (await res.json()) as ConversationComputerResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) computerId = data.computerId ?? null; + } catch (err) { + reportError("Failed to load computer", err); + } + } + + /** Refetch the workspace conversation's persisted model (works for a draft too). */ + async function refreshModel(): Promise { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/model`); + if (!res.ok) return; + const data = (await res.json()) as ModelResponse; + if (workspaceConversationId() !== id) return; + if (typeof data.model === "string" && data.model.length > 0) { + activeModel = data.model; + const activeId = tabsStore.activeConversationId; + if (activeId !== null) { + tabsStore.setModel(activeId, data.model); + chatStores.get(activeId)?.setModel(data.model); + } else { + draftStore.setModel(data.model); + } + } + } catch (err) { + reportError("Failed to load model", err); + } + } + + // The workspace conversation's persisted reasoning effort. Seeded from the + // backend on focus change; null = never set (the server default applies). + let reasoningEffort = $state(null); + + /** Refetch the workspace conversation's reasoning effort (works for a draft too). */ + async function refreshReasoningEffort(): Promise { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's level + // while the fetch is in flight (null renders as the server default). + reasoningEffort = null; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, + ); + if (!res.ok) return; + const data = (await res.json()) as ReasoningEffortResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; + } catch (err) { + reportError("Failed to load reasoning effort", err); + } + } + + // The workspace conversation's auto-compact percent. Seeded from the + // backend on focus change; null = not yet fetched. 0 = disabled. + let compactPercent = $state(null); + + /** Refetch the workspace conversation's compact percent (works for a draft too). */ + async function refreshCompactPercent(): Promise { + const id = workspaceConversationId(); + compactPercent = null; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, + ); + if (!res.ok) return; + const data = (await res.json()) as CompactPercentResponse; + if (workspaceConversationId() === id) compactPercent = data.threshold; + } catch (err) { + reportError("Failed to load compact percent", err); + } + } + + function getActiveChat(): ChatStore { + const activeId = tabsStore.activeConversationId; + if (activeId === null) { + return draftStore; + } + return chatStores.get(activeId) ?? draftStore; + } + + function refreshActiveChat(): void { + activeChat = getActiveChat(); + } + + function handleChatMessage(msg: ChatDeltaMessage | ChatErrorMessage): void { + let targetId: string | undefined; + if (msg.type === "chat.delta") { + targetId = msg.event.conversationId; + } else { + targetId = msg.conversationId; + } + + if (targetId !== undefined) { + const store = chatStores.get(targetId); + if (store !== undefined) { + store.handleDelta(msg); + return; + } + } + + // fallback: try all stores (chat.error without conversationId) + for (const store of chatStores.values()) { + store.handleDelta(msg); + } + } + + /** + * Start watching a conversation's live turn events (`chat.subscribe`). Sent for + * EVERY open conversation — not just the active one — so a backgrounded tab keeps + * streaming a running turn, and a reloaded/second client re-attaches to an + * in-flight turn (the server replays it from `turn-start`). Idempotent server-side; + * the socket queues it until the connection is open. NOT needed right after + * `chat.send` (that auto-subscribes the sending connection). + */ + function subscribeChat(conversationId: string): void { + socket?.send({ type: "chat.subscribe", conversationId }); + } + + /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */ + function unsubscribeChat(conversationId: string): void { + socket?.send({ type: "chat.unsubscribe", conversationId }); + } + + /** + * Tell the backend the user EXPLICITLY closed this conversation's tab + * (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with + * `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF). + * Distinct from a disconnect / `chat.unsubscribe`, which deliberately leave + * both running. Fire-and-forget: a failure is non-fatal (worst case the + * warming keeps running until a later close/toggle), and the endpoint is + * idempotent server-side. + */ + function closeConversation(conversationId: string): void { + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/close`, { + method: "POST", + }).catch((err) => { + reportError("Failed to close conversation", err); + }); + } + + /** The conversation the surfaces should scope to (undefined for a draft). */ + function focusedConversationId(): string | undefined { + return tabsStore.activeConversationId ?? undefined; + } + + /** + * The conversation id workspace settings (cwd / LSP) target: the active tab, or + * the pending draft's id when in draft mode. Unlike `focusedConversationId`, this + * is NEVER undefined — the draft has a stable client-minted id that survives + * promotion (first send), so a cwd set on a draft carries into the real turn. + */ + function workspaceConversationId(): string { + return tabsStore.activeConversationId ?? draftConversationId; + } + + function handleServerMessage(msg: SurfaceServerMessage): void { + protocol = applyServerMessage(protocol, msg); + // Surfaces are auto-expanded: whenever the catalog changes, subscribe to + // every entry (and drop subscriptions for entries that vanished). + if (msg.type === "catalog") { + syncSubscriptions(); + } + } + + /** + * Subscribe to every catalog entry, scoped to the focused conversation, and + * unsubscribe stragglers. Re-run on conversation switch: a conversation-scoped + * surface (e.g. cache-warming) re-scopes to the new id (`protocolSubscribe` + * emits unsubscribe-old + subscribe-new); a global surface ignores the id. + */ + function syncSubscriptions(): void { + const cid = focusedConversationId(); + for (const entry of protocol.catalog) { + // A GLOBAL surface ignores conversation scope — subscribe it WITHOUT an id + // so a conversation switch doesn't churn a redundant unsubscribe+subscribe + // round trip (ui-contract@0.2.0 catalog `scope`; ABSENT = assume + // conversation-scoped, the conservative pre-0.2.0 policy). + const scoped = entry.scope === "global" ? undefined : cid; + const result = protocolSubscribe(protocol, entry.id, scoped); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + const catalogIds = new Set(protocol.catalog.map((e) => e.id)); + for (const id of [...protocol.subscriptions.keys()]) { + if (!catalogIds.has(id)) { + const result = protocolUnsubscribe(protocol, id); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + } + } + + let socket: ReturnType | null = null; + + /** + * Open a conversation tab — used by the `conversation.open` WS broadcast + * (CLI `--open` flag) and by `conversation.statusChanged` when a new active + * conversation is discovered. If the conversation is already open, this is a + * no-op; otherwise create a chat store, load its history, subscribe to its live + * turns, and add the tab WITHOUT switching the active conversation (the user + * stays on their current tab; the new tab appears in the strip). The tab is + * stamped with the conversation's actual `workspaceId`, NOT the viewer's + * currently active workspace. + */ + function openConversation(conversationId: string, workspaceId: string): void { + if (chatStores.has(conversationId)) return; + const store = createChatFor(conversationId, activeModel, workspaceId); + chatStores.set(conversationId, store); + void store.load(); + subscribeChat(conversationId); + tabsStore.openTab({ + conversationId, + model: activeModel, + title: "Conversation", + workspaceId, + }); + } + + /** + * Remove a tab + its chat store locally (NO `POST /close` — used when the + * backend already marked the conversation `closed` via `conversation.statusChanged`). + */ + function removeTabLocally(conversationId: string): void { + unsubscribeChat(conversationId); + const store = chatStores.get(conversationId); + if (store !== undefined) { + store.dispose(); + chatStores.delete(conversationId); + } + void cache.delete(conversationId); + tabsStore.closeTab(conversationId); + conversationStatuses.delete(conversationId); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + } + + /** + * Surface a swallowed error to the user via the full-screen error modal + * (`fatalError` → `ErrorModal`). Logs to `console.error` too so the stack is + * in devtools. Called from catch blocks that previously swallowed errors silently. + */ + function reportError(context: string, err: unknown): void { + console.error(`[reportError] ${context}`, err); + const detail = + err instanceof Error + ? `${err.name}: ${err.message}\n\n${err.stack ?? "(no stack trace available)"}` + : String(err); + fatalError = `${context}\n\n${detail}`; + } + + // Conversation lifecycle status (backend-owned, pushed via WS + + // fetched on connect). Keyed by conversationId. + let conversationStatuses = $state>(new Map()); + + /** + * Fetch `GET /conversations?status=active,idle` on connect to restore the + * tab bar across devices. Merges: opens tabs for conversations not already + * open, removes tabs for conversations that are no longer active/idle + * (closed on another device), and subscribes to `active` conversations' + * live streams. + */ + async function fetchOpenConversations(): Promise { + try { + const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`); + if (!res.ok) return; + const data = (await res.json()) as ConversationListResponse; + + // Update the status map from the authoritative backend list. + const newStatuses = new Map(); + for (const conv of data.conversations) { + newStatuses.set(conv.id, conv.status); + } + conversationStatuses = newStatuses; + + // Open tabs for conversations not already open. + const existingIds = new Set(chatStores.keys()); + for (const conv of data.conversations) { + if (!existingIds.has(conv.id)) { + const store = createChatFor(conv.id, activeModel, conv.workspaceId); + chatStores.set(conv.id, store); + void store.load(); + subscribeChat(conv.id); + tabsStore.openTab({ + conversationId: conv.id, + model: activeModel, + title: conv.title, + workspaceId: conv.workspaceId, + }); + } else { + // Already open — update the title from the backend if it differs. + tabsStore.setTitle(conv.id, conv.title); + } + } + + // Remove tabs for conversations no longer active/idle (closed elsewhere). + const backendIds = new Set(data.conversations.map((c) => c.id)); + for (const tab of tabsStore.tabs) { + if (!backendIds.has(tab.conversationId)) { + removeTabLocally(tab.conversationId); + } + } + } catch (err) { + reportError( + `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`, + err, + ); + } + } + + const socketOpts: SurfaceSocketOptions = { + url: wsUrl, + onMessage: handleServerMessage, + onChat: handleChatMessage, + onConversationOpen(msg: ConversationOpenMessage): void { + openConversation(msg.conversationId, msg.workspaceId); + }, + onConversationStatusChanged(msg: ConversationStatusChangedMessage): void { + const { conversationId, status, workspaceId } = msg; + if (status === "closed") { + // Closed on another device (or the backend) — remove the tab locally. + if (chatStores.has(conversationId)) { + removeTabLocally(conversationId); + } + return; + } + // active / idle — update the status map (drives the tab spinner). + conversationStatuses = new Map(conversationStatuses).set(conversationId, status); + // If this is a new active conversation we don't have a tab for, open one. + if (status === "active" && !chatStores.has(conversationId)) { + openConversation(conversationId, workspaceId); + } + }, + onConversationCompacted(msg: ConversationCompactedMessage): void { + // Compaction keeps the conversation ID — the old full history is forked + // to an archive (newConversationId). Just reload the same conversation's + // history (dispose stale store + cache + re-fetch). + const cid = msg.conversationId; + const wasActive = tabsStore.activeConversationId === cid; + const store = chatStores.get(cid); + if (store !== undefined) { + store.dispose(); + } + void cache.delete(cid); + const fresh = createChatFor(cid, activeModel, activeWorkspaceId); + chatStores.set(cid, fresh); + void fresh.load(); + if (wasActive) { + refreshActiveChat(); + } + }, + onReopen() { + // The server forgot our subscriptions on reconnect; re-send each with the + // conversation it was subscribed under (protocolSubscribe would no-op since + // they're still in our local map, so emit the wire messages directly). + for (const [surfaceId, sub] of protocol.subscriptions) { + const msg: SubscribeMessage = + sub.conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId: sub.conversationId }; + socket?.send(msg); + } + // Re-attach to every open conversation's turn stream. A turn that kept + // running while we were disconnected resumes streaming (server replays it + // from `turn-start`); one that sealed while we were gone is committed from + // history by `resync()` (which also clears a now-stale "generating"). + for (const tab of tabsStore.tabs) { + subscribeChat(tab.conversationId); + chatStores.get(tab.conversationId)?.resync(); + } + }, + }; + if (opts?.socketFactory !== undefined) { + socketOpts.socketFactory = opts.socketFactory; + } + socket = createSurfaceSocket(socketOpts); + + // Fetch model catalog + void fetchImpl(`${httpBase}/models`) + .then((res) => { + if (!res.ok) return; + return res.json() as Promise; + }) + .then((data) => { + if (data === undefined) return; + models = data.models; + modelInfo = data.modelInfo ?? {}; + if (data.models.length > 0 && !data.models.includes(activeModel)) { + const first = data.models[0]; + if (first !== undefined) { + activeModel = first; + draftStore.setModel(first); + } + } + }) + .catch((err) => { + reportError("Failed to load model list", err); + }); + + // Fetch the discovered-computer catalog (global, like models). Empty until + // the `ssh` extension lands — a safe no-op until then (the selector shows + // "Local (none)" only). Non-fatal: a failure leaves an empty list. + void fetchImpl(`${httpBase}/computers`) + .then((res) => { + if (!res.ok) return { computers: [] } as ComputerListResponse; + return res.json() as Promise; + }) + .then((data) => { + computers = data?.computers ?? []; + }) + .catch((err) => { + reportError("Failed to load computer list", err); + }); + + // Restore persisted tabs + const persistedState = storageAdapter.load(); + if (persistedState !== null && persistedState.tabs.length > 0) { + for (const tab of persistedState.tabs) { + const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId); + chatStores.set(tab.conversationId, store); + void store.load(); + // Watch each restored conversation's live turns: after a reload mid-turn the + // server replays the in-flight turn so we keep rendering it. Queued until the + // socket opens. + subscribeChat(tab.conversationId); + } + if (persistedState.activeConversationId !== null) { + const activeTab = persistedState.tabs.find( + (t) => t.conversationId === persistedState.activeConversationId, + ); + if (activeTab !== undefined) { + activeModel = activeTab.model; + } + } + } + + refreshActiveChat(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + + // Fetch the authoritative open-conversation list from the backend (cross- + // device tab sync). Merges with the localStorage-restored tabs: opens new + // ones, removes closed ones, updates titles + statuses. + void fetchOpenConversations(); + + return { + get tabs(): readonly Tab[] { + return tabsStore.tabs.filter((t) => t.workspaceId === activeWorkspaceId); + }, + get activeConversationId(): string | null { + return tabsStore.activeConversationId; + }, + get activeWorkspaceId(): string { + return activeWorkspaceId; + }, + setActiveWorkspace(workspaceId: string): void { + activeWorkspaceId = workspaceId; + // Reset to a fresh draft scoped to the new workspace so a new chat is + // stamped with the right `workspaceId` on `chat.send`. + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel, workspaceId); + draftConversationId = nextDraftId; + tabsStore.newDraft(); + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + }, + get activeChat(): ChatStore { + return activeChat; + }, + get models(): readonly string[] { + return models; + }, + get modelInfo(): Readonly> { + return modelInfo; + }, + get activeModel(): string { + return activeModel; + }, + get catalog() { + return protocol.catalog; + }, + get surfaces(): readonly SurfaceSpec[] { + const out: SurfaceSpec[] = []; + for (const entry of protocol.catalog) { + const spec = getSurfaceSpec(protocol, entry.id); + if (spec) out.push(spec); + } + return out; + }, + get lastError() { + return protocol.lastError; + }, + get storage() { + return localStorageOpt; + }, + get cwd(): string | null { + return cwd; + }, + get computerId(): string | null { + return computerId; + }, + get computers(): readonly ComputerEntry[] { + return computers; + }, + get reasoningEffort(): ReasoningEffort | null { + return reasoningEffort; + }, + get compactPercent(): number | null { + return compactPercent; + }, + get chatLimit(): number { + return chatLimit; + }, + conversationStatus(conversationId: string): ConversationStatus | undefined { + return conversationStatuses.get(conversationId); + }, + get currentConversationId(): string { + return workspaceConversationId(); + }, + + surface(surfaceId: string): SurfaceSpec | null { + return getSurfaceSpec(protocol, surfaceId); + }, + + send(text: string): void { + if (tabsStore.activeConversationId === null) { + // Draft: promote to tab on first send + const conversationId = draftConversationId; + const model = activeModel; + tabsStore.createTab({ + conversationId, + model, + title: deriveTitle(text), + workspaceId: activeWorkspaceId, + }); + chatStores.set(conversationId, draftStore); + void draftStore.load(); + + // Prepare next draft + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); + draftConversationId = nextDraftId; + + refreshActiveChat(); + // The draft became a real conversation: re-scope conversation-scoped + // surfaces (e.g. cache-warming) to its id. + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + // Now send on the promoted store + chatStores.get(conversationId)?.send(text); + } else { + activeChat.send(text); + } + }, + + queueMessage(text: string): void { + // Only offered while generating (Composer switches to `chat.queue` + // when `status === "running"`), so a draft (never generating) never + // reaches here. `chat.queue` auto-starts a turn if idle, so even a race + // (turn sealed between the status read and the send) is safe — the + // server starts a fresh turn with the message as its opening prompt. + activeChat.queueMessage(text); + }, + + selectModel(model: string): void { + activeModel = model; + const activeId = tabsStore.activeConversationId; + if (activeId !== null) { + tabsStore.setModel(activeId, model); + chatStores.get(activeId)?.setModel(model); + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model } satisfies SetModelRequest), + }).catch((err) => { + reportError("Failed to persist model", err); + }); + } else { + draftStore.setModel(model); + } + }, + + newDraft(): void { + tabsStore.newDraft(); + const nextDraftId = randomId(); + draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId); + draftConversationId = nextDraftId; + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + }, + + selectTab(conversationId: string): void { + tabsStore.selectTab(conversationId); + const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId); + if (tab !== undefined) { + activeModel = tab.model; + } + refreshActiveChat(); + syncSubscriptions(); + void refreshCwd(); + void refreshComputer(); + void refreshModel(); + void refreshReasoningEffort(); + void refreshCompactPercent(); + }, + + closeTab(conversationId: string): void { + // The user is DONE with this chat: abort any in-flight turn + stop/disable + // its cache-warming, server-side (POST /close sets status → "closed"). + closeConversation(conversationId); + removeTabLocally(conversationId); + }, + + renameTab(conversationId: string, title: string): void { + tabsStore.setTitle(conversationId, title); + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title } satisfies SetTitleRequest), + }).catch((err) => { + reportError("Failed to rename conversation", err); + }); + }, + + invoke(surfaceId: string, actionId: string, payload?: unknown): void { + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + }, + + async warmNow(): Promise { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: WarmRequest = { conversationId, model: activeModel }; + try { + const res = await fetchImpl(`${httpBase}/chat/warm`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Warm failed (HTTP ${res.status})` }; + } + return { ok: true, response: (await res.json()) as WarmResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Warm request failed" }; + } + }, + + async setCwd(value: string): Promise { + const id = workspaceConversationId(); + const body: SetCwdRequest = { + cwd: value, + workspaceId: untrack(() => activeWorkspaceId), + }; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/cwd`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `Set cwd failed (HTTP ${res.status})` }; + } + const data = (await res.json()) as CwdResponse; + const next = data.cwd ?? null; + if (workspaceConversationId() === id) cwd = next; + return { ok: true, cwd: next }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set cwd request failed" }; + } + }, + + async setComputer(computerIdValue: string | null): Promise { + const id = workspaceConversationId(); + const body: SetConversationComputerRequest = { computerId: computerIdValue }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/computer`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set computer failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ConversationComputerResponse; + const next = data.computerId ?? null; + if (workspaceConversationId() === id) computerId = next; + return { ok: true, computerId: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set computer request failed", + }; + } + }, + + async computerStatus(alias: string): Promise { + if (alias === "") return null; + try { + const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/status`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Computer status failed (HTTP ${res.status})`, + }; + } + const status = (await res.json()) as ComputerStatusResponse; + return { ok: true, response: status }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Computer status request failed", + }; + } + }, + + async testComputer(alias: string): Promise { + if (alias === "") return null; + try { + const res = await fetchImpl(`${httpBase}/computers/${encodeURIComponent(alias)}/test`, { + method: "POST", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Test computer failed (HTTP ${res.status})`, + }; + } + const response = (await res.json()) as TestComputerResponse; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Test computer request failed", + }; + } + }, + + async setReasoningEffort(level: ReasoningEffort): Promise { + const id = workspaceConversationId(); + const body: SetReasoningEffortRequest = { reasoningEffort: level }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set reasoning effort failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ReasoningEffortResponse; + const next = data.reasoningEffort ?? level; + if (workspaceConversationId() === id) reasoningEffort = next; + return { ok: true, reasoningEffort: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set reasoning effort request failed", + }; + } + }, + + stopGeneration(): void { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return; + void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, { + method: "POST", + }).catch((err) => { + reportError("Failed to stop generation", err); + }); + }, + + async compactNow(keepLastN?: number): Promise { + const conversationId = tabsStore.activeConversationId; + if (conversationId === null) return null; + const body: Record = {}; + if (keepLastN !== undefined) body.keepLastN = keepLastN; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(conversationId)}/compact`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Compact failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as CompactResponse; + return { ok: true, response: data }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Compact request failed", + }; + } + }, + + async setCompactPercent(percent: number): Promise { + const id = workspaceConversationId(); + const body: SetCompactPercentRequest = { threshold: percent }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/compact-percent`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set compact percent failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as CompactPercentResponse; + if (workspaceConversationId() === id) compactPercent = data.threshold; + return { ok: true, percent: data.threshold }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set compact percent request failed", + }; + } + }, + + async setChatLimit(limit: number): Promise { + const next = normalizeChatLimit(limit); + chatLimitStore.save(next); + chatLimit = next; + // Propagate to every live chat store. The ACTIVE one is awaited so its + // refill (on a raise) lands before the caller returns — letting the + // shell preserve scroll over the prepended older chunks. Background + // stores refill fire-and-forget. Future stores pick the new limit up at + // creation (via the persisted store). + const active = getActiveChat(); + await active.setChatLimit(next); + for (const s of chatStores.values()) { + if (s !== active) void s.setChatLimit(next); + } + if (draftStore !== active) void draftStore.setChatLimit(next); + return { ok: true, chatLimit: next }; + }, + + async lspStatus(): Promise { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/lsp`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `LSP status failed (HTTP ${res.status})` }; + } + // Normalize the untyped body at this network seam so a malformed/partial + // response can never crash the renderer (servers is guaranteed an array). + const data = (await res.json()) as Partial; + const response: LspStatusResponse = { + conversationId: data.conversationId ?? id, + cwd: data.cwd ?? null, + servers: Array.isArray(data.servers) ? data.servers : [], + }; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "LSP status request failed", + }; + } + }, + + async mcpStatus(): Promise { + const id = workspaceConversationId(); + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/mcp`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { ok: false, error: errBody?.error ?? `MCP status failed (HTTP ${res.status})` }; + } + // Normalize the untyped body at this network seam so a malformed/partial + // response can never crash the renderer (servers is guaranteed an array). + const data = (await res.json()) as Partial; + const response: McpStatusResponse = { + conversationId: data.conversationId ?? id, + cwd: data.cwd ?? null, + servers: Array.isArray(data.servers) ? data.servers : [], + }; + return { ok: true, response }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "MCP status request failed", + }; + } + }, + + async loadSystemPrompt(): Promise { + try { + const res = await fetchImpl(`${httpBase}/system-prompt`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Load system prompt failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as SystemPromptTemplateResponse; + return { ok: true, template: data.template ?? "" }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Load system prompt request failed", + }; + } + }, + + async setSystemPrompt(template: string): Promise { + try { + const body: SetSystemPromptTemplateRequest = { template }; + const res = await fetchImpl(`${httpBase}/system-prompt`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Set system prompt failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as SystemPromptTemplateResponse; + return { ok: true, template: data.template }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set system prompt request failed", + }; + } + }, + + async loadSystemPromptVariables(): Promise { + try { + const res = await fetchImpl(`${httpBase}/system-prompt/variables`); + if (!res.ok) { + const errBody = (await res.json().catch(() => null)) as { error?: string } | null; + return { + ok: false, + error: errBody?.error ?? `Load system prompt variables failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as Partial; + return { ok: true, variables: Array.isArray(data.variables) ? data.variables : [] }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Load system prompt variables request failed", + }; + } + }, + + attachUnloadGate(gate: () => boolean): void { + unloadGate = gate; + }, + + get fatalError(): string | null { + return fatalError; + }, + clearFatalError(): void { + fatalError = null; + }, + + dispose(): void { + for (const store of chatStores.values()) { + store.dispose(); + } + chatStores.clear(); + draftStore.dispose(); + socket?.close(); + socket = null; + }, + }; } diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 5711442..4523167 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -5,50 +5,50 @@ import type { WebSocketLike } from "../adapters/ws"; import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - feedServerMessage(data: WsServerMessage): void; - feedSurfaceMessage(data: SurfaceServerMessage): void; + sent: string[]; + resolveOpen(): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - const sent: string[] = []; - - const ws: FakeSocket = { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return null; - }, - set onclose(_fn) {}, - resolveOpen() { - onopen?.(); - }, - feedServerMessage(msg: WsServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - feedSurfaceMessage(msg: SurfaceServerMessage) { - onmessage?.({ data: JSON.stringify(msg) }); - }, - sent, - }; - return ws; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + const sent: string[] = []; + + const ws: FakeSocket = { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return null; + }, + set onclose(_fn) {}, + resolveOpen() { + onopen?.(); + }, + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + sent, + }; + return ws; } /** @@ -57,1071 +57,1071 @@ function fakeSocket(): FakeSocket { * `sent` accumulates and `open()` can be driven again after `closeRemote()`. */ interface ReconnectableSocket extends WebSocketLike { - sent: string[]; - open(): void; - closeRemote(): void; + sent: string[]; + open(): void; + closeRemote(): void; } function reconnectableSocket(): ReconnectableSocket { - let onopen: (() => void) | null = null; - let onmessage: ((ev: { data: string }) => void) | null = null; - let onclose: ((ev: { code: number; reason: string }) => void) | null = null; - const sent: string[] = []; - return { - send(data: string) { - sent.push(data); - }, - close() {}, - get onopen() { - return onopen; - }, - set onopen(fn) { - onopen = fn; - }, - get onmessage() { - return onmessage; - }, - set onmessage(fn) { - onmessage = fn; - }, - get onclose() { - return onclose; - }, - set onclose(fn) { - onclose = fn; - }, - sent, - open() { - onopen?.(); - }, - closeRemote() { - onclose?.({ code: 1006, reason: "" }); - }, - }; + let onopen: (() => void) | null = null; + let onmessage: ((ev: { data: string }) => void) | null = null; + let onclose: ((ev: { code: number; reason: string }) => void) | null = null; + const sent: string[] = []; + return { + send(data: string) { + sent.push(data); + }, + close() {}, + get onopen() { + return onopen; + }, + set onopen(fn) { + onopen = fn; + }, + get onmessage() { + return onmessage; + }, + set onmessage(fn) { + onmessage = fn; + }, + get onclose() { + return onclose; + }, + set onclose(fn) { + onclose = fn; + }, + sent, + open() { + onopen?.(); + }, + closeRemote() { + onclose?.({ code: 1006, reason: "" }); + }, + }; } interface FakeFetchOptions { - models?: readonly string[]; - history?: Record; - model?: string | null; + models?: readonly string[]; + history?: Record; + model?: string | null; } function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch { - const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; - const history = opts?.history ?? {}; - return async (input: string | URL | Request): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models }), { status: 200 }); - } - if (url.endsWith("/model")) { - return new Response( - JSON.stringify({ - conversationId: "ignored", - model: opts?.model ?? null, - }), - { status: 200 }, - ); - } - const body = - history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); - return new Response(JSON.stringify(body), { status: 200 }); - }; + const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"]; + const history = opts?.history ?? {}; + return async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models }), { status: 200 }); + } + if (url.endsWith("/model")) { + return new Response( + JSON.stringify({ + conversationId: "ignored", + model: opts?.model ?? null, + }), + { status: 200 }, + ); + } + const body = + history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); + return new Response(JSON.stringify(body), { status: 200 }); + }; } function parseSent(ws: { sent: string[] }): unknown[] { - return ws.sent.map((s) => JSON.parse(s)); + return ws.sent.map((s) => JSON.parse(s)); } function createFakeStorage(): Storage { - const map = new Map(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string): string | null { - return map.get(key) ?? null; - }, - key(_index: number): string | null { - return null; - }, - removeItem(key: string) { - map.delete(key); - }, - setItem(key: string, value: string) { - map.set(key, value); - }, - }; + const map = new Map(); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key: string): string | null { + return map.get(key) ?? null; + }, + key(_index: number): string | null { + return null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } function activeConversationId(store: ReturnType): string { - const id = store.activeConversationId; - expect(id).not.toBeNull(); - return id as string; + const id = store.activeConversationId; + expect(id).not.toBeNull(); + return id as string; } describe("createAppStore", () => { - it("starts with empty catalog and no surfaces", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.catalog).toEqual([]); - expect(store.surfaces).toEqual([]); - expect(store.lastError).toBeNull(); - - store.dispose(); - }); - - it("updates catalog when catalog message arrives", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - expect(store.catalog).toHaveLength(2); - expect(store.catalog[0]?.id).toBe("s1"); - expect(store.catalog[1]?.id).toBe("s2"); - - store.dispose(); - }); - - it("auto-subscribes to every catalog entry when the catalog arrives", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.sent.length = 0; - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - const subscribed = ws.sent - .map((s) => JSON.parse(s)) - .filter((p) => p.type === "subscribe") - .map((p) => p.surfaceId); - expect(subscribed).toContain("s1"); - expect(subscribed).toContain("s2"); - - store.dispose(); - }); - - it("unsubscribes from entries that vanish from a new catalog", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - ws.sent.length = 0; - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], - }); - - const unsubscribed = ws.sent - .map((s) => JSON.parse(s)) - .filter((p) => p.type === "unsubscribe") - .map((p) => p.surfaceId); - expect(unsubscribed).toContain("s2"); - expect(unsubscribed).not.toContain("s1"); - - store.dispose(); - }); - - it("exposes received surface specs via `surfaces`, in catalog order", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s1", region: "sidebar", title: "Surface One" }, - { id: "s2", region: "panel", title: "Surface Two" }, - ], - }); - - // Only s1's spec has arrived: surfaces reflects what's actually received. - ws.feedSurfaceMessage({ - type: "surface", - spec: { - id: "s1", - region: "sidebar", - title: "Surface One", - fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], - }, - }); - expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]); - - ws.feedSurfaceMessage({ - type: "surface", - spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, - }); - // Catalog order preserved (s1 before s2). - expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]); - expect(store.surfaces[0]?.fields).toHaveLength(1); - - store.dispose(); - }); - - it("invoke sends an invoke message", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.sent.length = 0; - store.invoke("s1", "toggle-dark", true); - - const invokeMsg = ws.sent.find((s) => { - const parsed = JSON.parse(s); - return ( - parsed.type === "invoke" && - parsed.surfaceId === "s1" && - parsed.actionId === "toggle-dark" && - parsed.payload === true - ); - }); - expect(invokeMsg).toBeTruthy(); - - store.dispose(); - }); - - it("error message updates lastError", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "error", - message: "Something went wrong", - }); - - expect(store.lastError).not.toBeNull(); - expect(store.lastError?.message).toBe("Something went wrong"); - - store.dispose(); - }); - - it("dispose closes the socket", () => { - const ws = fakeSocket(); - const closeSpy = { called: false }; - const origClose = ws.close.bind(ws); - ws.close = () => { - closeSpy.called = true; - origClose(); - }; - - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - conversationId: "test-conv", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.dispose(); - expect(closeSpy.called).toBe(true); - }); - - it("exposes activeChat with empty initial messages", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.activeChat).toBeDefined(); - expect(store.activeChat.messages).toEqual([]); - expect(store.activeChat.chunks).toEqual([]); - expect(store.activeChat.error).toBeNull(); - - store.dispose(); - }); - - it("sending a message from draft creates a tab and posts chat.send", () => { - const ws = fakeSocket(); - const storage = createFakeStorage(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws.resolveOpen(); - - ws.sent.length = 0; - store.send("hello world"); - - expect(store.tabs).toHaveLength(1); - expect(store.tabs[0]?.title).toBe("hello world"); - expect(store.activeConversationId).not.toBeNull(); - - const msgs = parseSent(ws); - const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as - | { type: string; conversationId: string; message: string } - | undefined; - expect(chatSend).toBeTruthy(); - expect(chatSend?.message).toBe("hello world"); - - store.dispose(); - }); - - it("an incoming chat.delta renders in the transcript", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("test"); - const convId = activeConversationId(store); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" }, - }); - - expect(store.activeChat.chunks.length).toBeGreaterThan(0); - const assistantChunks = store.activeChat.chunks.filter( - (c) => c.role === "assistant" && c.chunk.type === "text", - ); - expect(assistantChunks).toHaveLength(1); - expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); - - store.dispose(); - }); - - it("chat.error sets the chat error", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("test"); - const convId = activeConversationId(store); - - ws.feedServerMessage({ - type: "chat.error", - conversationId: convId, - message: "bad request", - }); - - expect(store.activeChat.error).toBe("bad request"); - - store.dispose(); - }); - - it("turn-sealed triggers a history fetch and synced chunks render", async () => { - const fetchedUrls: string[] = []; - const historyResponse: ConversationHistoryResponse = { - chunks: [ - { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, - { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, - ], - latestSeq: 2, - }; - const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - fetchedUrls.push(url); - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - return new Response(JSON.stringify(historyResponse), { status: 200 }); - }; - - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - httpUrl: "http://localhost:24203", - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("hi"); - const convId = activeConversationId(store); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, - }); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); - - await new Promise((r) => setTimeout(r, 50)); - - expect(store.activeChat.chunks.length).toBeGreaterThan(0); - - store.dispose(); - }); - - it("fetches and exposes the model catalog", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl({ - models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], - }), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - await new Promise((r) => setTimeout(r, 50)); - - expect(store.models).toEqual([ - "opencode/deepseek-v4-flash", - "openai/gpt-4o", - "anthropic/claude-3", - ]); - - store.dispose(); - }); - - it("default model is flash", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); - - store.dispose(); - }); - - it("draft: sending the first message creates a tab titled from the message", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - - store.send("What is the meaning of life?"); - - expect(store.tabs).toHaveLength(1); - expect(store.tabs[0]?.title).toBe("What is the meaning of life?"); - expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId); - - store.dispose(); - }); - - it("selecting a model persists it to the backend", () => { - const ws = fakeSocket(); - const fetchImpl = fakeFetchImpl(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("hello"); - store.selectModel("openai/gpt-4o"); - - const put = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), { - status: 200, - }), - ); - const capturingStore = createAppStore({ - socketFactory: () => fakeSocket(), - fetchImpl: async (input, init) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (init?.method === "PUT" && url.endsWith("/model")) { - put(url, init); - } - return fetchImpl(input, init); - }, - localStorage: createFakeStorage(), - }); - capturingStore.send("hello"); - capturingStore.selectModel("openai/gpt-4o"); - - expect(put).toHaveBeenCalledOnce(); - const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit]; - expect(callUrl.endsWith("/model")).toBe(true); - expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" }); - - store.dispose(); - capturingStore.dispose(); - }); - - it("focuses a conversation with a persisted model", async () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first message"); - - // New tab opens with the default model until the persisted-model fetch resolves. - expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); - - // Wait for the persisted model fetch to resolve. - await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); - expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); - - store.dispose(); - }); - - it("chat.delta routes to the matching tab only", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first message"); - const convId1 = activeConversationId(store); - - store.newDraft(); - store.send("second message"); - const convId2 = activeConversationId(store); - - expect(convId1).not.toBe(convId2); - - ws.feedServerMessage({ - type: "chat.delta", - event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" }, - }); - ws.feedServerMessage({ - type: "chat.delta", - event: { - type: "text-delta", - conversationId: convId1, - turnId: "turn-1", - delta: "response to first", - }, - }); - - store.selectTab(convId1); - const assistantChunks1 = store.activeChat.chunks.filter( - (c) => c.role === "assistant" && c.chunk.type === "text", - ); - expect(assistantChunks1).toHaveLength(1); - expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe( - "response to first", - ); - - store.selectTab(convId2); - const assistantChunks2 = store.activeChat.chunks.filter( - (c) => c.role === "assistant" && c.chunk.type === "text", - ); - expect(assistantChunks2).toEqual([]); - - store.dispose(); - }); - - it("closing a tab evicts its cache and drops the tab", () => { - const ws = fakeSocket(); - const storage = createFakeStorage(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws.resolveOpen(); - - store.send("first"); - const convId = activeConversationId(store); - expect(store.tabs).toHaveLength(1); - - store.closeTab(convId); - - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - - store.dispose(); - }); - - it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => { - const calls: { url: string; method: string }[] = []; - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - calls.push({ url, method: init?.method ?? "GET" }); - if (url.endsWith("/close")) { - return new Response( - JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }), - { status: 200 }, - ); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - const convId = activeConversationId(store); - store.closeTab(convId); - await Promise.resolve(); // flush the fire-and-forget fetch - - const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`)); - expect(close).toBeDefined(); - expect(close?.method).toBe("POST"); - - store.dispose(); - }); - - it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", async () => { - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/reasoning-effort")) { - return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), { - status: 200, - }); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - await vi.waitFor(() => { - expect(store.reasoningEffort).toBe("xhigh"); - }); - - store.dispose(); - }); - - it("setReasoningEffort PUTs the level and updates local state from the echo", async () => { - const calls: { url: string; method: string; body: string | undefined }[] = []; - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined }); - if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { - const sent = JSON.parse(init.body as string) as { reasoningEffort: string }; - return new Response( - JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }), - { status: 200 }, - ); - } - if (url.endsWith("/reasoning-effort")) { - return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { - status: 200, - }); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - const result = await store.setReasoningEffort("max"); - expect(result).toEqual({ ok: true, reasoningEffort: "max" }); - expect(store.reasoningEffort).toBe("max"); - - const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort")); - expect(put).toBeDefined(); - // The PUT targets the workspace conversation (draft id works too) and - // carries exactly the SetReasoningEffortRequest body. - expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); - expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" }); - - store.dispose(); - }); - - it("setReasoningEffort surfaces a 400 error and leaves state unchanged", async () => { - const base = fakeFetchImpl(); - const fetchImpl: typeof fetch = async (input, init) => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { - return new Response(JSON.stringify({ error: "bad level" }), { status: 400 }); - } - if (url.endsWith("/reasoning-effort")) { - return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { - status: 200, - }); - } - return base(input, init); - }; - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - const result = await store.setReasoningEffort("max"); - expect(result).toEqual({ ok: false, error: "bad level" }); - expect(store.reasoningEffort).toBeNull(); - - store.dispose(); - }); - - it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - ws.feedSurfaceMessage({ - type: "catalog", - catalog: [ - { id: "s-global", region: "side", title: "Global", scope: "global" }, - { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" }, - ], - }); - - ws.sent.length = 0; - store.send("promote the draft"); // draft → real conversation: surfaces re-scope - const convId = activeConversationId(store); - - const surfaceMsgs = parseSent(ws).filter( - (p): p is { type: string; surfaceId: string; conversationId?: string } => - (p as { type: string }).type === "subscribe" || - (p as { type: string }).type === "unsubscribe", - ); - // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id. - expect( - surfaceMsgs.some( - (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId, - ), - ).toBe(true); - // The global surface is untouched — no redundant unsubscribe+subscribe round trip. - expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false); - - store.dispose(); - }); - - it("tabs persist to the injected storage and restore on a new store", () => { - const ws = fakeSocket(); - const storage = createFakeStorage(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws.resolveOpen(); - - store.send("persist me"); - const convId = store.tabs[0]?.conversationId; - const title = store.tabs[0]?.title; - expect(convId).toBeDefined(); - expect(title).toBeDefined(); - - const raw = storage.getItem("dispatch.tabs"); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw as string); - expect(parsed.tabs).toHaveLength(1); - expect(parsed.tabs[0].conversationId).toBe(convId); - expect(parsed.tabs[0].title).toBe(title); - - const ws2 = fakeSocket(); - const store2 = createAppStore({ - socketFactory: () => ws2, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws2.resolveOpen(); - - expect(store2.tabs).toHaveLength(1); - expect(store2.tabs[0]?.conversationId).toBe(convId); - expect(store2.tabs[0]?.title).toBe(title); - expect(store2.activeConversationId).toBe(convId); - - store.dispose(); - store2.dispose(); - }); - - it("tabs persist to globalThis.localStorage when no storage is injected", () => { - const realLs = globalThis.localStorage; - const memLs = createFakeStorage(); - globalThis.localStorage = memLs; - try { - const ws1 = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws1, - fetchImpl: fakeFetchImpl(), - }); - ws1.resolveOpen(); - - store.send("persist via default"); - const convId = store.tabs[0]?.conversationId; - const title = store.tabs[0]?.title; - expect(convId).toBeDefined(); - expect(title).toBeDefined(); - - const raw = globalThis.localStorage.getItem("dispatch.tabs"); - expect(raw).not.toBeNull(); - const parsed = JSON.parse(raw as string); - expect(parsed.tabs).toHaveLength(1); - expect(parsed.tabs[0].conversationId).toBe(convId); - expect(parsed.tabs[0].title).toBe(title); - - store.dispose(); - - const ws2 = fakeSocket(); - const store2 = createAppStore({ - socketFactory: () => ws2, - fetchImpl: fakeFetchImpl(), - }); - ws2.resolveOpen(); - - expect(store2.tabs).toHaveLength(1); - expect(store2.tabs[0]?.conversationId).toBe(convId); - expect(store2.tabs[0]?.title).toBe(title); - expect(store2.activeConversationId).toBe(convId); - - store2.dispose(); - } finally { - globalThis.localStorage = realLs; - } - }); - - it("newDraft resets to draft mode", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - expect(store.tabs).toHaveLength(1); - - store.newDraft(); - expect(store.activeConversationId).toBeNull(); - - store.dispose(); - }); - - it("selectTab switches active tab", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - const convId1 = activeConversationId(store); - - store.newDraft(); - store.send("second"); - const convId2 = activeConversationId(store); - - store.selectTab(convId1); - expect(store.activeConversationId).toBe(convId1); - - store.selectTab(convId2); - expect(store.activeConversationId).toBe(convId2); - - store.dispose(); - }); - - it("subscribes to chat for each restored tab on page load", () => { - const storage = createFakeStorage(); - // First session: create a tab, then dispose. - const ws1 = fakeSocket(); - const store1 = createAppStore({ - socketFactory: () => ws1, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws1.resolveOpen(); - store1.send("persist me"); - const convId = store1.tabs[0]?.conversationId as string; - expect(convId).toBeDefined(); - store1.dispose(); - - // Second session: the restored tab must be re-subscribed for live turns. - const ws2 = fakeSocket(); - const store2 = createAppStore({ - socketFactory: () => ws2, - fetchImpl: fakeFetchImpl(), - localStorage: storage, - }); - ws2.resolveOpen(); // flush the queued chat.subscribe - - const subscribed = parseSent(ws2) - .filter((p) => (p as { type: string }).type === "chat.subscribe") - .map((p) => (p as { conversationId: string }).conversationId); - expect(subscribed).toContain(convId); - - store2.dispose(); - }); - - it("unsubscribes from chat when a tab is closed", () => { - const ws = fakeSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), - }); - ws.resolveOpen(); - - store.send("first"); - const convId = activeConversationId(store); - - ws.sent.length = 0; - store.closeTab(convId); - - const unsubscribed = parseSent(ws) - .filter((p) => (p as { type: string }).type === "chat.unsubscribe") - .map((p) => (p as { conversationId: string }).conversationId); - expect(unsubscribed).toContain(convId); - - store.dispose(); - }); - - it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => { - const fetchedUrls: string[] = []; - const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise => { - const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - fetchedUrls.push(url); - if (url.endsWith("/models")) { - return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { - status: 200, - }); - } - return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); - }; - - const ws = reconnectableSocket(); - const store = createAppStore({ - socketFactory: () => ws, - fetchImpl, - httpUrl: "http://localhost:24203", - localStorage: createFakeStorage(), - }); - ws.open(); - - store.send("hi"); - const convId = activeConversationId(store); - - // Drop the connection, wait past the reconnect backoff, then re-open. - ws.sent.length = 0; - fetchedUrls.length = 0; - ws.closeRemote(); - await new Promise((r) => setTimeout(r, 800)); - ws.open(); // reconnect → onReopen - - const subscribed = parseSent(ws) - .filter((p) => (p as { type: string }).type === "chat.subscribe") - .map((p) => (p as { conversationId: string }).conversationId); - expect(subscribed).toContain(convId); - - // resync() pulled the tail from history for the reconnected conversation. - await vi.waitFor(() => { - expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); - }); - - store.dispose(); - }); + it("starts with empty catalog and no surfaces", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.catalog).toEqual([]); + expect(store.surfaces).toEqual([]); + expect(store.lastError).toBeNull(); + + store.dispose(); + }); + + it("updates catalog when catalog message arrives", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + expect(store.catalog).toHaveLength(2); + expect(store.catalog[0]?.id).toBe("s1"); + expect(store.catalog[1]?.id).toBe("s2"); + + store.dispose(); + }); + + it("auto-subscribes to every catalog entry when the catalog arrives", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.sent.length = 0; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + const subscribed = ws.sent + .map((s) => JSON.parse(s)) + .filter((p) => p.type === "subscribe") + .map((p) => p.surfaceId); + expect(subscribed).toContain("s1"); + expect(subscribed).toContain("s2"); + + store.dispose(); + }); + + it("unsubscribes from entries that vanish from a new catalog", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + ws.sent.length = 0; + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], + }); + + const unsubscribed = ws.sent + .map((s) => JSON.parse(s)) + .filter((p) => p.type === "unsubscribe") + .map((p) => p.surfaceId); + expect(unsubscribed).toContain("s2"); + expect(unsubscribed).not.toContain("s1"); + + store.dispose(); + }); + + it("exposes received surface specs via `surfaces`, in catalog order", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s1", region: "sidebar", title: "Surface One" }, + { id: "s2", region: "panel", title: "Surface Two" }, + ], + }); + + // Only s1's spec has arrived: surfaces reflects what's actually received. + ws.feedSurfaceMessage({ + type: "surface", + spec: { + id: "s1", + region: "sidebar", + title: "Surface One", + fields: [{ kind: "stat", label: "Tokens", value: "1,234" }], + }, + }); + expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]); + + ws.feedSurfaceMessage({ + type: "surface", + spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] }, + }); + // Catalog order preserved (s1 before s2). + expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]); + expect(store.surfaces[0]?.fields).toHaveLength(1); + + store.dispose(); + }); + + it("invoke sends an invoke message", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.invoke("s1", "toggle-dark", true); + + const invokeMsg = ws.sent.find((s) => { + const parsed = JSON.parse(s); + return ( + parsed.type === "invoke" && + parsed.surfaceId === "s1" && + parsed.actionId === "toggle-dark" && + parsed.payload === true + ); + }); + expect(invokeMsg).toBeTruthy(); + + store.dispose(); + }); + + it("error message updates lastError", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "error", + message: "Something went wrong", + }); + + expect(store.lastError).not.toBeNull(); + expect(store.lastError?.message).toBe("Something went wrong"); + + store.dispose(); + }); + + it("dispose closes the socket", () => { + const ws = fakeSocket(); + const closeSpy = { called: false }; + const origClose = ws.close.bind(ws); + ws.close = () => { + closeSpy.called = true; + origClose(); + }; + + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.dispose(); + expect(closeSpy.called).toBe(true); + }); + + it("exposes activeChat with empty initial messages", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.activeChat).toBeDefined(); + expect(store.activeChat.messages).toEqual([]); + expect(store.activeChat.chunks).toEqual([]); + expect(store.activeChat.error).toBeNull(); + + store.dispose(); + }); + + it("sending a message from draft creates a tab and posts chat.send", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.send("hello world"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("hello world"); + expect(store.activeConversationId).not.toBeNull(); + + const msgs = parseSent(ws); + const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as + | { type: string; conversationId: string; message: string } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.message).toBe("hello world"); + + store.dispose(); + }); + + it("an incoming chat.delta renders in the transcript", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("test"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" }, + }); + + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + const assistantChunks = store.activeChat.chunks.filter( + (c) => c.role === "assistant" && c.chunk.type === "text", + ); + expect(assistantChunks).toHaveLength(1); + expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); + + store.dispose(); + }); + + it("chat.error sets the chat error", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("test"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "chat.error", + conversationId: convId, + message: "bad request", + }); + + expect(store.activeChat.error).toBe("bad request"); + + store.dispose(); + }); + + it("turn-sealed triggers a history fetch and synced chunks render", async () => { + const fetchedUrls: string[] = []; + const historyResponse: ConversationHistoryResponse = { + chunks: [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, + ], + latestSeq: 2, + }; + const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchedUrls.push(url); + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + return new Response(JSON.stringify(historyResponse), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + httpUrl: "http://localhost:24203", + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("hi"); + const convId = activeConversationId(store); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId, turnId: "turn-1" }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.activeChat.chunks.length).toBeGreaterThan(0); + + store.dispose(); + }); + + it("fetches and exposes the model catalog", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ + models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"], + }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.models).toEqual([ + "opencode/deepseek-v4-flash", + "openai/gpt-4o", + "anthropic/claude-3", + ]); + + store.dispose(); + }); + + it("default model is flash", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + + store.dispose(); + }); + + it("draft: sending the first message creates a tab titled from the message", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.send("What is the meaning of life?"); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs[0]?.title).toBe("What is the meaning of life?"); + expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId); + + store.dispose(); + }); + + it("selecting a model persists it to the backend", () => { + const ws = fakeSocket(); + const fetchImpl = fakeFetchImpl(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("hello"); + store.selectModel("openai/gpt-4o"); + + const put = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), { + status: 200, + }), + ); + const capturingStore = createAppStore({ + socketFactory: () => fakeSocket(), + fetchImpl: async (input, init) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (init?.method === "PUT" && url.endsWith("/model")) { + put(url, init); + } + return fetchImpl(input, init); + }, + localStorage: createFakeStorage(), + }); + capturingStore.send("hello"); + capturingStore.selectModel("openai/gpt-4o"); + + expect(put).toHaveBeenCalledOnce(); + const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit]; + expect(callUrl.endsWith("/model")).toBe(true); + expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" }); + + store.dispose(); + capturingStore.dispose(); + }); + + it("focuses a conversation with a persisted model", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first message"); + + // New tab opens with the default model until the persisted-model fetch resolves. + expect(store.activeModel).toBe("opencode/deepseek-v4-flash"); + + // Wait for the persisted model fetch to resolve. + await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o")); + expect(store.tabs[0]?.model).toBe("openai/gpt-4o"); + + store.dispose(); + }); + + it("chat.delta routes to the matching tab only", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first message"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second message"); + const convId2 = activeConversationId(store); + + expect(convId1).not.toBe(convId2); + + ws.feedServerMessage({ + type: "chat.delta", + event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" }, + }); + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: convId1, + turnId: "turn-1", + delta: "response to first", + }, + }); + + store.selectTab(convId1); + const assistantChunks1 = store.activeChat.chunks.filter( + (c) => c.role === "assistant" && c.chunk.type === "text", + ); + expect(assistantChunks1).toHaveLength(1); + expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe( + "response to first", + ); + + store.selectTab(convId2); + const assistantChunks2 = store.activeChat.chunks.filter( + (c) => c.role === "assistant" && c.chunk.type === "text", + ); + expect(assistantChunks2).toEqual([]); + + store.dispose(); + }); + + it("closing a tab evicts its cache and drops the tab", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + expect(store.tabs).toHaveLength(1); + + store.closeTab(convId); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => { + const calls: { url: string; method: string }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET" }); + if (url.endsWith("/close")) { + return new Response( + JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }), + { status: 200 }, + ); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + store.closeTab(convId); + await Promise.resolve(); // flush the fire-and-forget fetch + + const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`)); + expect(close).toBeDefined(); + expect(close?.method).toBe("POST"); + + store.dispose(); + }); + + it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", async () => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.reasoningEffort).toBe("xhigh"); + }); + + store.dispose(); + }); + + it("setReasoningEffort PUTs the level and updates local state from the echo", async () => { + const calls: { url: string; method: string; body: string | undefined }[] = []; + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined }); + if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { + const sent = JSON.parse(init.body as string) as { reasoningEffort: string }; + return new Response( + JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }), + { status: 200 }, + ); + } + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setReasoningEffort("max"); + expect(result).toEqual({ ok: true, reasoningEffort: "max" }); + expect(store.reasoningEffort).toBe("max"); + + const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort")); + expect(put).toBeDefined(); + // The PUT targets the workspace conversation (draft id works too) and + // carries exactly the SetReasoningEffortRequest body. + expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); + expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" }); + + store.dispose(); + }); + + it("setReasoningEffort surfaces a 400 error and leaves state unchanged", async () => { + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/reasoning-effort") && init?.method === "PUT") { + return new Response(JSON.stringify({ error: "bad level" }), { status: 400 }); + } + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setReasoningEffort("max"); + expect(result).toEqual({ ok: false, error: "bad level" }); + expect(store.reasoningEffort).toBeNull(); + + store.dispose(); + }); + + it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + ws.feedSurfaceMessage({ + type: "catalog", + catalog: [ + { id: "s-global", region: "side", title: "Global", scope: "global" }, + { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" }, + ], + }); + + ws.sent.length = 0; + store.send("promote the draft"); // draft → real conversation: surfaces re-scope + const convId = activeConversationId(store); + + const surfaceMsgs = parseSent(ws).filter( + (p): p is { type: string; surfaceId: string; conversationId?: string } => + (p as { type: string }).type === "subscribe" || + (p as { type: string }).type === "unsubscribe", + ); + // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id. + expect( + surfaceMsgs.some( + (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId, + ), + ).toBe(true); + // The global surface is untouched — no redundant unsubscribe+subscribe round trip. + expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false); + + store.dispose(); + }); + + it("tabs persist to the injected storage and restore on a new store", () => { + const ws = fakeSocket(); + const storage = createFakeStorage(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws.resolveOpen(); + + store.send("persist me"); + const convId = store.tabs[0]?.conversationId; + const title = store.tabs[0]?.title; + expect(convId).toBeDefined(); + expect(title).toBeDefined(); + + const raw = storage.getItem("dispatch.tabs"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw as string); + expect(parsed.tabs).toHaveLength(1); + expect(parsed.tabs[0].conversationId).toBe(convId); + expect(parsed.tabs[0].title).toBe(title); + + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws2.resolveOpen(); + + expect(store2.tabs).toHaveLength(1); + expect(store2.tabs[0]?.conversationId).toBe(convId); + expect(store2.tabs[0]?.title).toBe(title); + expect(store2.activeConversationId).toBe(convId); + + store.dispose(); + store2.dispose(); + }); + + it("tabs persist to globalThis.localStorage when no storage is injected", () => { + const realLs = globalThis.localStorage; + const memLs = createFakeStorage(); + globalThis.localStorage = memLs; + try { + const ws1 = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws1, + fetchImpl: fakeFetchImpl(), + }); + ws1.resolveOpen(); + + store.send("persist via default"); + const convId = store.tabs[0]?.conversationId; + const title = store.tabs[0]?.title; + expect(convId).toBeDefined(); + expect(title).toBeDefined(); + + const raw = globalThis.localStorage.getItem("dispatch.tabs"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw as string); + expect(parsed.tabs).toHaveLength(1); + expect(parsed.tabs[0].conversationId).toBe(convId); + expect(parsed.tabs[0].title).toBe(title); + + store.dispose(); + + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + }); + ws2.resolveOpen(); + + expect(store2.tabs).toHaveLength(1); + expect(store2.tabs[0]?.conversationId).toBe(convId); + expect(store2.tabs[0]?.title).toBe(title); + expect(store2.activeConversationId).toBe(convId); + + store2.dispose(); + } finally { + globalThis.localStorage = realLs; + } + }); + + it("newDraft resets to draft mode", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + expect(store.tabs).toHaveLength(1); + + store.newDraft(); + expect(store.activeConversationId).toBeNull(); + + store.dispose(); + }); + + it("selectTab switches active tab", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId1 = activeConversationId(store); + + store.newDraft(); + store.send("second"); + const convId2 = activeConversationId(store); + + store.selectTab(convId1); + expect(store.activeConversationId).toBe(convId1); + + store.selectTab(convId2); + expect(store.activeConversationId).toBe(convId2); + + store.dispose(); + }); + + it("subscribes to chat for each restored tab on page load", () => { + const storage = createFakeStorage(); + // First session: create a tab, then dispose. + const ws1 = fakeSocket(); + const store1 = createAppStore({ + socketFactory: () => ws1, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws1.resolveOpen(); + store1.send("persist me"); + const convId = store1.tabs[0]?.conversationId as string; + expect(convId).toBeDefined(); + store1.dispose(); + + // Second session: the restored tab must be re-subscribed for live turns. + const ws2 = fakeSocket(); + const store2 = createAppStore({ + socketFactory: () => ws2, + fetchImpl: fakeFetchImpl(), + localStorage: storage, + }); + ws2.resolveOpen(); // flush the queued chat.subscribe + + const subscribed = parseSent(ws2) + .filter((p) => (p as { type: string }).type === "chat.subscribe") + .map((p) => (p as { conversationId: string }).conversationId); + expect(subscribed).toContain(convId); + + store2.dispose(); + }); + + it("unsubscribes from chat when a tab is closed", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + store.send("first"); + const convId = activeConversationId(store); + + ws.sent.length = 0; + store.closeTab(convId); + + const unsubscribed = parseSent(ws) + .filter((p) => (p as { type: string }).type === "chat.unsubscribe") + .map((p) => (p as { conversationId: string }).conversationId); + expect(unsubscribed).toContain(convId); + + store.dispose(); + }); + + it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => { + const fetchedUrls: string[] = []; + const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchedUrls.push(url); + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), { + status: 200, + }); + } + return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); + }; + + const ws = reconnectableSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + httpUrl: "http://localhost:24203", + localStorage: createFakeStorage(), + }); + ws.open(); + + store.send("hi"); + const convId = activeConversationId(store); + + // Drop the connection, wait past the reconnect backoff, then re-open. + ws.sent.length = 0; + fetchedUrls.length = 0; + ws.closeRemote(); + await new Promise((r) => setTimeout(r, 800)); + ws.open(); // reconnect → onReopen + + const subscribed = parseSent(ws) + .filter((p) => (p as { type: string }).type === "chat.subscribe") + .map((p) => (p as { conversationId: string }).conversationId); + expect(subscribed).toContain(convId); + + // resync() pulled the tail from history for the reconnected conversation. + await vi.waitFor(() => { + expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true); + }); + + store.dispose(); + }); }); diff --git a/src/app/uuid.test.ts b/src/app/uuid.test.ts index bd8e306..7673db1 100644 --- a/src/app/uuid.test.ts +++ b/src/app/uuid.test.ts @@ -4,28 +4,28 @@ import { randomId } from "./uuid"; const V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; describe("randomId", () => { - it("returns a v4-shaped uuid", () => { - const id = randomId(); - expect(id).toMatch(V4_RE); - }); + it("returns a v4-shaped uuid", () => { + const id = randomId(); + expect(id).toMatch(V4_RE); + }); - it("returns distinct values across calls", () => { - const ids = new Set(); - for (let i = 0; i < 200; i++) { - ids.add(randomId()); - } - expect(ids.size).toBe(200); - }); + it("returns distinct values across calls", () => { + const ids = new Set(); + for (let i = 0; i < 200; i++) { + ids.add(randomId()); + } + expect(ids.size).toBe(200); + }); - it("works without crypto.randomUUID (getRandomValues branch)", () => { - const origRandomUUID = crypto.randomUUID; - try { - // Remove randomUUID so the getRandomValues branch is taken - delete (crypto as { randomUUID?: () => string }).randomUUID; - const id = randomId(); - expect(id).toMatch(V4_RE); - } finally { - crypto.randomUUID = origRandomUUID; - } - }); + it("works without crypto.randomUUID (getRandomValues branch)", () => { + const origRandomUUID = crypto.randomUUID; + try { + // Remove randomUUID so the getRandomValues branch is taken + delete (crypto as { randomUUID?: () => string }).randomUUID; + const id = randomId(); + expect(id).toMatch(V4_RE); + } finally { + crypto.randomUUID = origRandomUUID; + } + }); }); diff --git a/src/app/uuid.ts b/src/app/uuid.ts index ae39d4d..bdceefe 100644 --- a/src/app/uuid.ts +++ b/src/app/uuid.ts @@ -1,65 +1,65 @@ const HEX = "0123456789abcdef"; function hexChar(n: number): string { - return HEX.charAt(n & 0xf); + return HEX.charAt(n & 0xf); } function hexFromBytes(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i++) { - const b = bytes[i] as number; - out += hexChar(b >> 4); - out += hexChar(b); - } - return out; + let out = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i] as number; + out += hexChar(b >> 4); + out += hexChar(b); + } + return out; } function formatV4(rand: Uint8Array): string { - const h = hexFromBytes(rand); - return ( - h.slice(0, 8) + - "-" + - h.slice(8, 12) + - "-4" + - h.slice(13, 16) + - "-" + - ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") + - h.slice(18, 20) + - "-" + - h.slice(20, 32) - ); + const h = hexFromBytes(rand); + return ( + h.slice(0, 8) + + "-" + + h.slice(8, 12) + + "-4" + + h.slice(13, 16) + + "-" + + ((parseInt(h.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, "0") + + h.slice(18, 20) + + "-" + + h.slice(20, 32) + ); } function uuidFromGetRandomValues(): string { - const buf = new Uint8Array(16); - crypto.getRandomValues(buf); - buf[6] = ((buf[6] as number) & 0x0f) | 0x40; - buf[8] = ((buf[8] as number) & 0x3f) | 0x80; - return formatV4(buf); + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + buf[6] = ((buf[6] as number) & 0x0f) | 0x40; + buf[8] = ((buf[8] as number) & 0x3f) | 0x80; + return formatV4(buf); } function uuidFromMathRandom(): string { - let s = ""; - for (let i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) { - s += "-"; - } else if (i === 14) { - s += "4"; - } else if (i === 19) { - s += hexChar(Math.floor(Math.random() * 4) + 8); - } else { - s += hexChar(Math.floor(Math.random() * 16)); - } - } - return s; + let s = ""; + for (let i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + s += "-"; + } else if (i === 14) { + s += "4"; + } else if (i === 19) { + s += hexChar(Math.floor(Math.random() * 4) + 8); + } else { + s += hexChar(Math.floor(Math.random() * 16)); + } + } + return s; } export function randomId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { - return uuidFromGetRandomValues(); - } - return uuidFromMathRandom(); + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + return uuidFromGetRandomValues(); + } + return uuidFromMathRandom(); } diff --git a/src/components/Table.svelte b/src/components/Table.svelte index 7c56e69..b6f5c5a 100644 --- a/src/components/Table.svelte +++ b/src/components/Table.svelte @@ -1,42 +1,42 @@
-
- - - {#each columns as col, i (i)} - - {/each} - - - - {#if rows.length === 0} - - - - {:else} - {#each rows as row, r (r)} - - {#each row as cell, c (c)} - - {/each} - - {/each} - {/if} - -
{col}
{empty}
{cell}
+ + + + {#each columns as col, i (i)} + + {/each} + + + + {#if rows.length === 0} + + + + {:else} + {#each rows as row, r (r)} + + {#each row as cell, c (c)} + + {/each} + + {/each} + {/if} + +
{col}
{empty}
{cell}
diff --git a/src/components/Table.test.ts b/src/components/Table.test.ts index 9fbecd3..f43a981 100644 --- a/src/components/Table.test.ts +++ b/src/components/Table.test.ts @@ -3,33 +3,33 @@ import { describe, expect, it } from "vitest"; import Table from "./Table.svelte"; describe("Table", () => { - it("renders a header cell per column", () => { - render(Table, { props: { columns: ["Name", "Version"], rows: [] } }); - const headers = screen.getAllByRole("columnheader"); - expect(headers.map((h) => h.textContent)).toEqual(["Name", "Version"]); - }); + it("renders a header cell per column", () => { + render(Table, { props: { columns: ["Name", "Version"], rows: [] } }); + const headers = screen.getAllByRole("columnheader"); + expect(headers.map((h) => h.textContent)).toEqual(["Name", "Version"]); + }); - it("renders one row per data row with aligned cells", () => { - render(Table, { - props: { - columns: ["Name", "Version"], - rows: [ - ["alpha", "1.0"], - ["beta", "2.3"], - ], - }, - }); - const body = screen.getAllByRole("rowgroup")[1]; - if (body === undefined) throw new Error("expected a tbody rowgroup"); - const rows = within(body).getAllByRole("row"); - expect(rows).toHaveLength(2); - expect(within(rows[0] as HTMLElement).getByText("alpha")).toBeInTheDocument(); - expect(within(rows[0] as HTMLElement).getByText("1.0")).toBeInTheDocument(); - expect(within(rows[1] as HTMLElement).getByText("beta")).toBeInTheDocument(); - }); + it("renders one row per data row with aligned cells", () => { + render(Table, { + props: { + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }, + }); + const body = screen.getAllByRole("rowgroup")[1]; + if (body === undefined) throw new Error("expected a tbody rowgroup"); + const rows = within(body).getAllByRole("row"); + expect(rows).toHaveLength(2); + expect(within(rows[0] as HTMLElement).getByText("alpha")).toBeInTheDocument(); + expect(within(rows[0] as HTMLElement).getByText("1.0")).toBeInTheDocument(); + expect(within(rows[1] as HTMLElement).getByText("beta")).toBeInTheDocument(); + }); - it("shows the empty message when there are no rows", () => { - render(Table, { props: { columns: ["A"], rows: [], empty: "Nothing loaded" } }); - expect(screen.getByText("Nothing loaded")).toBeInTheDocument(); - }); + it("shows the empty message when there are no rows", () => { + render(Table, { props: { columns: ["A"], rows: [], empty: "Nothing loaded" } }); + expect(screen.getByText("Nothing loaded")).toBeInTheDocument(); + }); }); diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts index fbfda83..c8b0fa2 100644 --- a/src/core/chunks/groups.test.ts +++ b/src/core/chunks/groups.test.ts @@ -4,122 +4,122 @@ import { groupRenderedChunks } from "./groups"; import type { RenderedChunk } from "./types"; const text = (seq: number, role: Role, t: string, provisional = false): RenderedChunk => ({ - seq, - role, - chunk: { type: "text", text: t }, - provisional, + seq, + role, + chunk: { type: "text", text: t }, + provisional, }); const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ - seq, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId: id, - toolName: `tool-${id}`, - input: { id }, - ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), - }, - provisional, + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: id, + toolName: `tool-${id}`, + input: { id }, + ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), + }, + provisional, }); const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({ - seq, - role: "tool", - chunk: { - type: "tool-result", - toolCallId: id, - toolName: `tool-${id}`, - content: `result-${id}`, - isError: false, - ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), - }, - provisional, + seq, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: id, + toolName: `tool-${id}`, + content: `result-${id}`, + isError: false, + ...(stepId !== undefined ? { stepId: stepId as StepId } : {}), + }, + provisional, }); describe("groupRenderedChunks", () => { - it("returns no groups for an empty stream", () => { - expect(groupRenderedChunks([])).toEqual([]); - }); + it("returns no groups for an empty stream", () => { + expect(groupRenderedChunks([])).toEqual([]); + }); - it("passes non-tool chunks through as single groups, in order", () => { - const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]); - expect(groups).toHaveLength(2); - expect(groups.every((g) => g.kind === "single")).toBe(true); - }); + it("passes non-tool chunks through as single groups, in order", () => { + const groups = groupRenderedChunks([text(1, "user", "hi"), text(2, "assistant", "hello")]); + expect(groups).toHaveLength(2); + expect(groups.every((g) => g.kind === "single")).toBe(true); + }); - it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => { - const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]); - expect(groups).toHaveLength(2); - expect(groups.map((g) => g.kind)).toEqual(["single", "single"]); - }); + it("does NOT batch a single tool call (one per step) — call+result stay separate singles", () => { + const groups = groupRenderedChunks([call(1, "a", "s1"), result(2, "a", "s1")]); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.kind)).toEqual(["single", "single"]); + }); - it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => { - const groups = groupRenderedChunks([ - call(1, "a"), - call(2, "b"), - result(3, "a"), - result(4, "b"), - ]); - expect(groups).toHaveLength(4); - expect(groups.every((g) => g.kind === "single")).toBe(true); - }); + it("does NOT batch tool calls that have no stepId (pre-0.2.0 replay)", () => { + const groups = groupRenderedChunks([ + call(1, "a"), + call(2, "b"), + result(3, "a"), + result(4, "b"), + ]); + expect(groups).toHaveLength(4); + expect(groups.every((g) => g.kind === "single")).toBe(true); + }); - it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => { - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - result(3, "a", "s1"), - result(4, "b", "s1"), - ]); - expect(groups).toHaveLength(1); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.stepId).toBe("s1"); - expect(g.entries).toHaveLength(2); - expect(g.entries[0]?.call.toolCallId).toBe("a"); - expect(g.entries[0]?.result?.content).toBe("result-a"); - expect(g.entries[1]?.call.toolCallId).toBe("b"); - expect(g.entries[1]?.result?.content).toBe("result-b"); - }); + it("batches 2+ calls sharing a stepId into one group, pairing each with its result", () => { + const groups = groupRenderedChunks([ + call(1, "a", "s1"), + call(2, "b", "s1"), + result(3, "a", "s1"), + result(4, "b", "s1"), + ]); + expect(groups).toHaveLength(1); + const g = groups[0]; + if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(g.stepId).toBe("s1"); + expect(g.entries).toHaveLength(2); + expect(g.entries[0]?.call.toolCallId).toBe("a"); + expect(g.entries[0]?.result?.content).toBe("result-a"); + expect(g.entries[1]?.call.toolCallId).toBe("b"); + expect(g.entries[1]?.result?.content).toBe("result-b"); + }); - it("positions the batch at the first call and keeps surrounding chunks in order", () => { - const groups = groupRenderedChunks([ - text(1, "assistant", "before"), - call(2, "a", "s1"), - call(3, "b", "s1"), - result(4, "a", "s1"), - result(5, "b", "s1"), - text(6, "assistant", "after"), - ]); - expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); - }); + it("positions the batch at the first call and keeps surrounding chunks in order", () => { + const groups = groupRenderedChunks([ + text(1, "assistant", "before"), + call(2, "a", "s1"), + call(3, "b", "s1"), + result(4, "a", "s1"), + result(5, "b", "s1"), + text(6, "assistant", "after"), + ]); + expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]); + }); - it("marks the batch provisional when any of its calls/results is provisional", () => { - const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]); - const g = groups[0]; - if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(g.provisional).toBe(true); - expect(g.entries).toHaveLength(2); - expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet) - }); + it("marks the batch provisional when any of its calls/results is provisional", () => { + const groups = groupRenderedChunks([call(1, "a", "s1"), call(2, "b", "s1", true)]); + const g = groups[0]; + if (g?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(g.provisional).toBe(true); + expect(g.entries).toHaveLength(2); + expect(g.entries[1]?.result).toBeNull(); // dangling call (no result yet) + }); - it("batches one step while leaving a different single-call step ungrouped", () => { - const groups = groupRenderedChunks([ - call(1, "a", "s1"), - call(2, "b", "s1"), - call(3, "c", "s2"), - result(4, "a", "s1"), - result(5, "b", "s1"), - result(6, "c", "s2"), - ]); - expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]); - const batch = groups[0]; - if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); - expect(batch.entries).toHaveLength(2); - // the s2 single call + its result remain as separate single groups - const singles = groups.slice(1); - expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call"); - expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result"); - }); + it("batches one step while leaving a different single-call step ungrouped", () => { + const groups = groupRenderedChunks([ + call(1, "a", "s1"), + call(2, "b", "s1"), + call(3, "c", "s2"), + result(4, "a", "s1"), + result(5, "b", "s1"), + result(6, "c", "s2"), + ]); + expect(groups.map((g) => g.kind)).toEqual(["tool-batch", "single", "single"]); + const batch = groups[0]; + if (batch?.kind !== "tool-batch") throw new Error("expected a tool-batch group"); + expect(batch.entries).toHaveLength(2); + // the s2 single call + its result remain as separate single groups + const singles = groups.slice(1); + expect(singles[0]?.kind === "single" && singles[0].chunk.chunk.type).toBe("tool-call"); + expect(singles[1]?.kind === "single" && singles[1].chunk.chunk.type).toBe("tool-result"); + }); }); diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts index 6dc7e10..53a2873 100644 --- a/src/core/chunks/groups.ts +++ b/src/core/chunks/groups.ts @@ -6,8 +6,8 @@ import type { RenderedChunk } from "./types"; * `result` is null while the call is still pending (no result chunk yet). */ export interface ToolBatchEntry { - readonly call: ToolCallChunk; - readonly result: ToolResultChunk | null; + readonly call: ToolCallChunk; + readonly result: ToolResultChunk | null; } /** @@ -16,13 +16,13 @@ export interface ToolBatchEntry { * rendered as one grouped unit. */ export type RenderGroup = - | { readonly kind: "single"; readonly chunk: RenderedChunk } - | { - readonly kind: "tool-batch"; - readonly stepId: string; - readonly entries: readonly ToolBatchEntry[]; - readonly provisional: boolean; - }; + | { readonly kind: "single"; readonly chunk: RenderedChunk } + | { + readonly kind: "tool-batch"; + readonly stepId: string; + readonly entries: readonly ToolBatchEntry[]; + readonly provisional: boolean; + }; /** * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId` @@ -35,61 +35,61 @@ export type RenderGroup = * Pure: input → output, no DOM, no Svelte. */ export function groupRenderedChunks(rendered: readonly RenderedChunk[]): readonly RenderGroup[] { - // 1. Steps that batched 2+ tool calls. - const callsPerStep = new Map(); - for (const rc of rendered) { - if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) { - callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1); - } - } - const batchSteps = new Set(); - for (const [stepId, count] of callsPerStep) { - if (count >= 2) batchSteps.add(stepId); - } + // 1. Steps that batched 2+ tool calls. + const callsPerStep = new Map(); + for (const rc of rendered) { + if (rc.chunk.type === "tool-call" && rc.chunk.stepId !== undefined) { + callsPerStep.set(rc.chunk.stepId, (callsPerStep.get(rc.chunk.stepId) ?? 0) + 1); + } + } + const batchSteps = new Set(); + for (const [stepId, count] of callsPerStep) { + if (count >= 2) batchSteps.add(stepId); + } - // 2. toolCallIds belonging to a batch (so their results are absorbed), and a - // lookup of result chunks by toolCallId for pairing. - const batchCallIds = new Set(); - const resultByCallId = new Map(); - for (const rc of rendered) { - const chunk = rc.chunk; - if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { - batchCallIds.add(chunk.toolCallId); - } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) { - resultByCallId.set(chunk.toolCallId, chunk); - } - } + // 2. toolCallIds belonging to a batch (so their results are absorbed), and a + // lookup of result chunks by toolCallId for pairing. + const batchCallIds = new Set(); + const resultByCallId = new Map(); + for (const rc of rendered) { + const chunk = rc.chunk; + if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { + batchCallIds.add(chunk.toolCallId); + } else if (chunk.type === "tool-result" && !resultByCallId.has(chunk.toolCallId)) { + resultByCallId.set(chunk.toolCallId, chunk); + } + } - // 3. Emit groups in stream order; each batch lands at its first call. - const groups: RenderGroup[] = []; - const emittedSteps = new Set(); - for (const rc of rendered) { - const chunk = rc.chunk; + // 3. Emit groups in stream order; each batch lands at its first call. + const groups: RenderGroup[] = []; + const emittedSteps = new Set(); + for (const rc of rendered) { + const chunk = rc.chunk; - if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { - const stepId = chunk.stepId; - if (emittedSteps.has(stepId)) continue; - emittedSteps.add(stepId); + if (chunk.type === "tool-call" && chunk.stepId !== undefined && batchSteps.has(chunk.stepId)) { + const stepId = chunk.stepId; + if (emittedSteps.has(stepId)) continue; + emittedSteps.add(stepId); - const entries: ToolBatchEntry[] = []; - let provisional = false; - for (const inner of rendered) { - if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { - const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; - entries.push({ call: inner.chunk, result }); - if (inner.provisional) provisional = true; - } - } - groups.push({ kind: "tool-batch", stepId, entries, provisional }); - continue; - } + const entries: ToolBatchEntry[] = []; + let provisional = false; + for (const inner of rendered) { + if (inner.chunk.type === "tool-call" && inner.chunk.stepId === stepId) { + const result = resultByCallId.get(inner.chunk.toolCallId) ?? null; + entries.push({ call: inner.chunk, result }); + if (inner.provisional) provisional = true; + } + } + groups.push({ kind: "tool-batch", stepId, entries, provisional }); + continue; + } - if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { - continue; // absorbed into its batch - } + if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) { + continue; // absorbed into its batch + } - groups.push({ kind: "single", chunk: rc }); - } + groups.push({ kind: "single", chunk: rc }); + } - return groups; + return groups; } diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts index 162531d..eea2303 100644 --- a/src/core/chunks/index.ts +++ b/src/core/chunks/index.ts @@ -1,30 +1,30 @@ export type { RenderGroup, ToolBatchEntry } from "./groups"; export { groupRenderedChunks } from "./groups"; export { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, } from "./reducer"; export type { ProviderRetryView } from "./retry-banner"; export { formatRetryDelay, viewProviderRetry } from "./retry-banner"; export { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors"; export { - DEFAULT_CHAT_LIMIT, - initialWindowSize, - MAX_CHAT_LIMIT, - MIN_CHAT_LIMIT, - normalizeChatLimit, - restoreEarlier, - selectHasEarlier, - trimTranscript, - unloadCount, - windowTranscript, + DEFAULT_CHAT_LIMIT, + initialWindowSize, + MAX_CHAT_LIMIT, + MIN_CHAT_LIMIT, + normalizeChatLimit, + restoreEarlier, + selectHasEarlier, + trimTranscript, + unloadCount, + windowTranscript, } from "./trim"; export type { - AccumulatingChunk, - ProvisionalChunk, - RenderedChunk, - TranscriptState, + AccumulatingChunk, + ProvisionalChunk, + RenderedChunk, + TranscriptState, } from "./types"; diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts index ac9b895..8a2e1b7 100644 --- a/src/core/chunks/reducer.test.ts +++ b/src/core/chunks/reducer.test.ts @@ -1,871 +1,871 @@ import type { - StepId, - StoredChunk, - TurnDoneEvent, - TurnErrorEvent, - TurnInputEvent, - TurnProviderRetryEvent, - TurnReasoningDeltaEvent, - TurnSealedEvent, - TurnStartEvent, - TurnSteeringEvent, - TurnTextDeltaEvent, - TurnToolCallEvent, - TurnToolResultEvent, - TurnUsageEvent, + StepId, + StoredChunk, + TurnDoneEvent, + TurnErrorEvent, + TurnInputEvent, + TurnProviderRetryEvent, + TurnReasoningDeltaEvent, + TurnSealedEvent, + TurnStartEvent, + TurnSteeringEvent, + TurnTextDeltaEvent, + TurnToolCallEvent, + TurnToolResultEvent, + TurnUsageEvent, } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - appendUserMessage, - applyHistory, - clearGenerating, - foldEvent, - initialState, + appendUserMessage, + applyHistory, + clearGenerating, + foldEvent, + initialState, } from "./reducer"; import { selectChunks, selectGenerating, selectMessages, selectProviderRetry } from "./selectors"; const turnStart = (turnId: string): TurnStartEvent => ({ - type: "turn-start", - conversationId: "c1", - turnId, + type: "turn-start", + conversationId: "c1", + turnId, }); const textDelta = (turnId: string, delta: string): TurnTextDeltaEvent => ({ - type: "text-delta", - conversationId: "c1", - turnId, - delta, + type: "text-delta", + conversationId: "c1", + turnId, + delta, }); const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent => ({ - type: "reasoning-delta", - conversationId: "c1", - turnId, - delta, + type: "reasoning-delta", + conversationId: "c1", + turnId, + delta, }); const toolCall = ( - turnId: string, - toolCallId: string, - toolName: string, - input: unknown, - stepId = "s0", + turnId: string, + toolCallId: string, + toolName: string, + input: unknown, + stepId = "s0", ): TurnToolCallEvent => ({ - type: "tool-call", - conversationId: "c1", - turnId, - toolCallId, - toolName, - input, - stepId: stepId as StepId, + type: "tool-call", + conversationId: "c1", + turnId, + toolCallId, + toolName, + input, + stepId: stepId as StepId, }); const toolResult = ( - turnId: string, - toolCallId: string, - toolName: string, - content: string, - stepId = "s0", + turnId: string, + toolCallId: string, + toolName: string, + content: string, + stepId = "s0", ): TurnToolResultEvent => ({ - type: "tool-result", - conversationId: "c1", - turnId, - toolCallId, - toolName, - content, - isError: false, - stepId: stepId as StepId, + type: "tool-result", + conversationId: "c1", + turnId, + toolCallId, + toolName, + content, + isError: false, + stepId: stepId as StepId, }); const usageEvent = (turnId: string, inputTokens: number, outputTokens: number): TurnUsageEvent => ({ - type: "usage", - conversationId: "c1", - turnId, - usage: { inputTokens, outputTokens }, + type: "usage", + conversationId: "c1", + turnId, + usage: { inputTokens, outputTokens }, }); const errorEvent = (turnId: string, message: string, code?: string): TurnErrorEvent => - code !== undefined - ? { type: "error", conversationId: "c1", turnId, message, code } - : { type: "error", conversationId: "c1", turnId, message }; + code !== undefined + ? { type: "error", conversationId: "c1", turnId, message, code } + : { type: "error", conversationId: "c1", turnId, message }; const doneEvent = (turnId: string): TurnDoneEvent => ({ - type: "done", - conversationId: "c1", - turnId, - reason: "stop", + type: "done", + conversationId: "c1", + turnId, + reason: "stop", }); const turnSealed = (turnId: string): TurnSealedEvent => ({ - type: "turn-sealed", - conversationId: "c1", - turnId, + type: "turn-sealed", + conversationId: "c1", + turnId, }); const providerRetry = ( - turnId: string, - attempt: number, - delayMs: number, - message = "HTTP 429: overloaded", - code?: string, + turnId: string, + attempt: number, + delayMs: number, + message = "HTTP 429: overloaded", + code?: string, ): TurnProviderRetryEvent => - code !== undefined - ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code } - : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message }; + code !== undefined + ? { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message, code } + : { type: "provider-retry", conversationId: "c1", turnId, attempt, delayMs, message }; const storedChunk = ( - seq: number, - role: "user" | "assistant" | "tool" | "system", - chunk: StoredChunk["chunk"], + seq: number, + role: "user" | "assistant" | "tool" | "system", + chunk: StoredChunk["chunk"], ): StoredChunk => ({ - seq, - role, - chunk, + seq, + role, + chunk, }); describe("initialState", () => { - it("initial state is empty", () => { - const s = initialState(); - expect(s.committed).toEqual([]); - expect(s.provisional).toEqual([]); - expect(s.accumulating).toBeNull(); - expect(s.currentTurnId).toBeNull(); - expect(s.latestUsage).toBeNull(); - expect(s.sealedTurnId).toBeNull(); - expect(s.generating).toBe(false); - }); + it("initial state is empty", () => { + const s = initialState(); + expect(s.committed).toEqual([]); + expect(s.provisional).toEqual([]); + expect(s.accumulating).toBeNull(); + expect(s.currentTurnId).toBeNull(); + expect(s.latestUsage).toBeNull(); + expect(s.sealedTurnId).toBeNull(); + expect(s.generating).toBe(false); + }); }); describe("foldEvent — generating (turn-running state)", () => { - it("turn-start sets generating true", () => { - let s = initialState(); - expect(selectGenerating(s)).toBe(false); - s = foldEvent(s, turnStart("t1")); - expect(s.generating).toBe(true); - expect(selectGenerating(s)).toBe(true); - }); - - it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => { - let s = initialState(); - s = foldEvent(s, textDelta("t1", "hi")); - expect(s.generating).toBe(true); - s = initialState(); - s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.generating).toBe(true); - s = initialState(); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); - expect(s.generating).toBe(true); - }); - - it("stays generating across the turn's deltas", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "wor")); - s = foldEvent(s, textDelta("t1", "king")); - expect(s.generating).toBe(true); - }); - - it("done clears generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "answer")); - s = foldEvent(s, doneEvent("t1")); - expect(s.generating).toBe(false); - }); - - it("turn-sealed clears generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, turnSealed("t1")); - expect(s.generating).toBe(false); - }); - - it("error clears generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, errorEvent("t1", "boom")); - expect(s.generating).toBe(false); - }); - - it("a new turn re-asserts generating after the previous one finished", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, doneEvent("t1")); - s = foldEvent(s, turnSealed("t1")); - expect(s.generating).toBe(false); - s = foldEvent(s, turnStart("t2")); - expect(s.generating).toBe(true); - }); - - it("status does not change generating (free-form string, not inferred)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" }); - expect(next.generating).toBe(true); - }); + it("turn-start sets generating true", () => { + let s = initialState(); + expect(selectGenerating(s)).toBe(false); + s = foldEvent(s, turnStart("t1")); + expect(s.generating).toBe(true); + expect(selectGenerating(s)).toBe(true); + }); + + it("a content delta sets generating true (e.g. a late-joiner replay missing turn-start)", () => { + let s = initialState(); + s = foldEvent(s, textDelta("t1", "hi")); + expect(s.generating).toBe(true); + s = initialState(); + s = foldEvent(s, reasoningDelta("t1", "hmm")); + expect(s.generating).toBe(true); + s = initialState(); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(s.generating).toBe(true); + }); + + it("stays generating across the turn's deltas", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "wor")); + s = foldEvent(s, textDelta("t1", "king")); + expect(s.generating).toBe(true); + }); + + it("done clears generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "answer")); + s = foldEvent(s, doneEvent("t1")); + expect(s.generating).toBe(false); + }); + + it("turn-sealed clears generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, turnSealed("t1")); + expect(s.generating).toBe(false); + }); + + it("error clears generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, errorEvent("t1", "boom")); + expect(s.generating).toBe(false); + }); + + it("a new turn re-asserts generating after the previous one finished", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, doneEvent("t1")); + s = foldEvent(s, turnSealed("t1")); + expect(s.generating).toBe(false); + s = foldEvent(s, turnStart("t2")); + expect(s.generating).toBe(true); + }); + + it("status does not change generating (free-form string, not inferred)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + const next = foldEvent(s, { type: "status", conversationId: "c1", status: "idle" }); + expect(next.generating).toBe(true); + }); }); describe("clearGenerating", () => { - it("clears a set generating flag", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - expect(s.generating).toBe(true); - const cleared = clearGenerating(s); - expect(cleared.generating).toBe(false); - }); - - it("returns the same object when already not generating (no-op)", () => { - const s = initialState(); - expect(clearGenerating(s)).toBe(s); - }); - - it("preserves transcript content while clearing generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - const cleared = clearGenerating(s); - expect(cleared.generating).toBe(false); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); - expect(cleared.currentTurnId).toBe("t1"); - }); + it("clears a set generating flag", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + expect(s.generating).toBe(true); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + }); + + it("returns the same object when already not generating (no-op)", () => { + const s = initialState(); + expect(clearGenerating(s)).toBe(s); + }); + + it("preserves transcript content while clearing generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(cleared.currentTurnId).toBe("t1"); + }); }); describe("foldEvent — text-delta", () => { - it("text-delta accumulates into one TextChunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); - expect(s.provisional).toEqual([]); - }); - - it("successive text-deltas extend the same provisional chunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello ")); - s = foldEvent(s, textDelta("t1", "world")); - expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); - expect(s.provisional).toEqual([]); - }); - - it("text-delta after reasoning-delta flushes thinking and starts text", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, reasoningDelta("t1", "thinking...")); - s = foldEvent(s, textDelta("t1", "answer")); - expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); - expect(s.provisional[0]?.role).toBe("assistant"); - }); + it("text-delta accumulates into one TextChunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + expect(s.accumulating).toEqual({ kind: "text", text: "hello" }); + expect(s.provisional).toEqual([]); + }); + + it("successive text-deltas extend the same provisional chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello ")); + s = foldEvent(s, textDelta("t1", "world")); + expect(s.accumulating).toEqual({ kind: "text", text: "hello world" }); + expect(s.provisional).toEqual([]); + }); + + it("text-delta after reasoning-delta flushes thinking and starts text", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "thinking...")); + s = foldEvent(s, textDelta("t1", "answer")); + expect(s.accumulating).toEqual({ kind: "text", text: "answer" }); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." }); + expect(s.provisional[0]?.role).toBe("assistant"); + }); }); describe("foldEvent — reasoning-delta", () => { - it("reasoning-delta yields a thinking chunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, reasoningDelta("t1", "hmm")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); - }); - - it("successive reasoning-deltas extend the same chunk", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, reasoningDelta("t1", "hmm ")); - s = foldEvent(s, reasoningDelta("t1", "ok")); - expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); - }); + it("reasoning-delta yields a thinking chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "hmm")); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" }); + }); + + it("successive reasoning-deltas extend the same chunk", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, reasoningDelta("t1", "hmm ")); + s = foldEvent(s, reasoningDelta("t1", "ok")); + expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok" }); + }); }); describe("foldEvent — tool-call then tool-result", () => { - it("tool-call then tool-result render in order", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0")); - s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0")); - expect(s.provisional).toHaveLength(2); - expect(s.provisional[0]?.role).toBe("assistant"); - // foldEvent copies the event's stepId onto the chunk (grouping key). - expect(s.provisional[0]?.chunk).toEqual({ - type: "tool-call", - toolCallId: "tc1", - toolName: "bash", - input: { cmd: "ls" }, - stepId: "t1#0", - }); - expect(s.provisional[1]?.role).toBe("tool"); - expect(s.provisional[1]?.chunk).toEqual({ - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - content: "file.txt", - isError: false, - stepId: "t1#0", - }); - }); - - it("tool-call flushes accumulating text", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "let me check")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); - expect(s.provisional).toHaveLength(2); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" }); - expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" }); - expect(s.accumulating).toBeNull(); - }); + it("tool-call then tool-result render in order", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", { cmd: "ls" }, "t1#0")); + s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt", "t1#0")); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.role).toBe("assistant"); + // foldEvent copies the event's stepId onto the chunk (grouping key). + expect(s.provisional[0]?.chunk).toEqual({ + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { cmd: "ls" }, + stepId: "t1#0", + }); + expect(s.provisional[1]?.role).toBe("tool"); + expect(s.provisional[1]?.chunk).toEqual({ + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + content: "file.txt", + isError: false, + stepId: "t1#0", + }); + }); + + it("tool-call flushes accumulating text", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "let me check")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "let me check" }); + expect(s.provisional[1]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "tc1" }); + expect(s.accumulating).toBeNull(); + }); }); describe("foldEvent — turn-sealed", () => { - it("turn-sealed sets sealedTurnId", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hi")); - s = foldEvent(s, turnSealed("t1")); - expect(s.sealedTurnId).toBe("t1"); - expect(s.accumulating).toBeNull(); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" }); - }); + it("turn-sealed sets sealedTurnId", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hi")); + s = foldEvent(s, turnSealed("t1")); + expect(s.sealedTurnId).toBe("t1"); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hi" }); + }); }); describe("foldEvent — usage", () => { - it("stores latest usage", () => { - let s = initialState(); - s = foldEvent(s, usageEvent("t1", 100, 50)); - expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 }); - }); - - it("overwrites previous usage", () => { - let s = initialState(); - s = foldEvent(s, usageEvent("t1", 100, 50)); - s = foldEvent(s, usageEvent("t1", 200, 80)); - expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 }); - }); + it("stores latest usage", () => { + let s = initialState(); + s = foldEvent(s, usageEvent("t1", 100, 50)); + expect(s.latestUsage).toEqual({ inputTokens: 100, outputTokens: 50 }); + }); + + it("overwrites previous usage", () => { + let s = initialState(); + s = foldEvent(s, usageEvent("t1", 100, 50)); + s = foldEvent(s, usageEvent("t1", 200, 80)); + expect(s.latestUsage).toEqual({ inputTokens: 200, outputTokens: 80 }); + }); }); describe("foldEvent — error", () => { - it("creates error chunk with code", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, errorEvent("t1", "bad", "E001")); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" }); - expect(s.provisional[0]?.role).toBe("assistant"); - }); - - it("creates error chunk without code", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, errorEvent("t1", "bad")); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" }); - }); + it("creates error chunk with code", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, errorEvent("t1", "bad", "E001")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad", code: "E001" }); + expect(s.provisional[0]?.role).toBe("assistant"); + }); + + it("creates error chunk without code", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, errorEvent("t1", "bad")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "error", message: "bad" }); + }); }); describe("foldEvent — done", () => { - it("flushes accumulating chunk on done", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello")); - s = foldEvent(s, doneEvent("t1")); - expect(s.accumulating).toBeNull(); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" }); - }); + it("flushes accumulating chunk on done", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + s = foldEvent(s, doneEvent("t1")); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); }); describe("foldEvent — status and tool-output", () => { - it("status is a no-op", () => { - const s = initialState(); - const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); - expect(next).toBe(s); - }); - - it("tool-output is a no-op", () => { - const s = initialState(); - const next = foldEvent(s, { - type: "tool-output", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - data: "output", - stream: "stdout", - }); - expect(next).toBe(s); - }); + it("status is a no-op", () => { + const s = initialState(); + const next = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); + expect(next).toBe(s); + }); + + it("tool-output is a no-op", () => { + const s = initialState(); + const next = foldEvent(s, { + type: "tool-output", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + data: "output", + stream: "stdout", + }); + expect(next).toBe(s); + }); }); describe("foldEvent — provider-retry (transient retry banner)", () => { - it("sets the provider-retry banner on a provider-retry event", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429")); - const retry = selectProviderRetry(s); - expect(retry).not.toBeNull(); - expect(retry?.attempt).toBe(0); - expect(retry?.delayMs).toBe(5000); - expect(retry?.code).toBe("429"); - }); - - it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - expect(selectChunks(s)).toHaveLength(0); - expect(s.provisional).toHaveLength(0); - expect(s.accumulating).toBeNull(); - }); - - it("coalesces: the latest attempt + delay replaces the previous", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429")); - s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429")); - const retry = selectProviderRetry(s); - expect(retry?.attempt).toBe(1); - expect(retry?.delayMs).toBe(10000); - expect(retry?.message).toBe("second"); - }); - - it("keeps generating true (the turn is still in flight, just retrying)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - expect(s.generating).toBe(true); - }); - - it("clears when content resumes (text-delta)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - expect(selectProviderRetry(s)).not.toBeNull(); - s = foldEvent(s, textDelta("t1", "here is the reply")); - expect(selectProviderRetry(s)).toBeNull(); - }); - - it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - s = foldEvent(s, reasoningDelta("t1", "thinking")); - expect(selectProviderRetry(s)).toBeNull(); - - s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); - expect(selectProviderRetry(s)).toBeNull(); - }); - - it("clears when the turn ends (done / turn-sealed / error)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - s = foldEvent(s, errorEvent("t1", "exhausted")); - expect(selectProviderRetry(s)).toBeNull(); - - s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - s = foldEvent(s, doneEvent("t1")); - expect(selectProviderRetry(s)).toBeNull(); - - s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - s = foldEvent(s, turnSealed("t1")); - expect(selectProviderRetry(s)).toBeNull(); - }); - - it("clears on a new turn (turn-start)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - expect(selectProviderRetry(s)).not.toBeNull(); - s = foldEvent(s, turnStart("t2")); - expect(selectProviderRetry(s)).toBeNull(); - }); - - it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - s = foldEvent(s, usageEvent("t1", 10, 20)); - expect(selectProviderRetry(s)).not.toBeNull(); - s = foldEvent(s, { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 100, - decodeMs: 200, - genTotalMs: 300, - }); - expect(selectProviderRetry(s)).not.toBeNull(); - s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); - expect(selectProviderRetry(s)).not.toBeNull(); - }); - - it("is null in the initial state", () => { - expect(selectProviderRetry(initialState())).toBeNull(); - }); + it("sets the provider-retry banner on a provider-retry event", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000, "HTTP 429: overloaded", "429")); + const retry = selectProviderRetry(s); + expect(retry).not.toBeNull(); + expect(retry?.attempt).toBe(0); + expect(retry?.delayMs).toBe(5000); + expect(retry?.code).toBe("429"); + }); + + it("does NOT add a chunk (never persisted — never pollutes the prompt)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectChunks(s)).toHaveLength(0); + expect(s.provisional).toHaveLength(0); + expect(s.accumulating).toBeNull(); + }); + + it("coalesces: the latest attempt + delay replaces the previous", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000, "first", "429")); + s = foldEvent(s, providerRetry("t1", 1, 10000, "second", "429")); + const retry = selectProviderRetry(s); + expect(retry?.attempt).toBe(1); + expect(retry?.delayMs).toBe(10000); + expect(retry?.message).toBe("second"); + }); + + it("keeps generating true (the turn is still in flight, just retrying)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(s.generating).toBe(true); + }); + + it("clears when content resumes (text-delta)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, textDelta("t1", "here is the reply")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears when content resumes (reasoning-delta / tool-call / tool-result)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, reasoningDelta("t1", "thinking")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears when the turn ends (done / turn-sealed / error)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, errorEvent("t1", "exhausted")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, doneEvent("t1")); + expect(selectProviderRetry(s)).toBeNull(); + + s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, turnSealed("t1")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("clears on a new turn (turn-start)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, turnStart("t2")); + expect(selectProviderRetry(s)).toBeNull(); + }); + + it("leaves the banner untouched across metadata events (usage / step-complete / status)", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + s = foldEvent(s, usageEvent("t1", 10, 20)); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 100, + decodeMs: 200, + genTotalMs: 300, + }); + expect(selectProviderRetry(s)).not.toBeNull(); + s = foldEvent(s, { type: "status", conversationId: "c1", status: "running" }); + expect(selectProviderRetry(s)).not.toBeNull(); + }); + + it("is null in the initial state", () => { + expect(selectProviderRetry(initialState())).toBeNull(); + }); }); describe("clearGenerating also clears a stale provider-retry banner (reconnect)", () => { - it("clears the retry banner alongside generating on reconnect", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - expect(s.generating).toBe(true); - expect(selectProviderRetry(s)).not.toBeNull(); - const cleared = clearGenerating(s); - expect(cleared.generating).toBe(false); - expect(selectProviderRetry(cleared)).toBeNull(); - }); - - it("preserves transcript content while clearing the banner", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - s = foldEvent(s, providerRetry("t1", 0, 5000)); - const cleared = clearGenerating(s); - expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); - expect(selectProviderRetry(cleared)).toBeNull(); - }); + it("clears the retry banner alongside generating on reconnect", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + expect(s.generating).toBe(true); + expect(selectProviderRetry(s)).not.toBeNull(); + const cleared = clearGenerating(s); + expect(cleared.generating).toBe(false); + expect(selectProviderRetry(cleared)).toBeNull(); + }); + + it("preserves transcript content while clearing the banner", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = foldEvent(s, providerRetry("t1", 0, 5000)); + const cleared = clearGenerating(s); + expect(cleared.accumulating).toEqual({ kind: "text", text: "partial" }); + expect(selectProviderRetry(cleared)).toBeNull(); + }); }); describe("foldEvent — user-message (the turn's user prompt; backend CR-3)", () => { - const userMessage = (text: string): TurnInputEvent => ({ - type: "user-message", - conversationId: "c1", - turnId: "t1", - text, - }); - - it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => { - let s = initialState(); - s = foldEvent(s, userMessage("what is 2+2?")); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" }); - expect(chunks[0]?.provisional).toBe(true); - expect(s.generating).toBe(true); - }); - - it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => { - let s = initialState(); - s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send() - s = foldEvent(s, userMessage("hi")); // server echo for the same turn - const users = selectChunks(s).filter((c) => c.role === "user"); - expect(users).toHaveLength(1); - }); - - it("appends when the trailing provisional differs (no false dedup)", () => { - let s = initialState(); - s = appendUserMessage(s, "first"); - s = foldEvent(s, userMessage("second")); - const users = selectChunks(s).filter((c) => c.role === "user"); - expect(users).toHaveLength(2); - }); - - it("ignores an empty user-message", () => { - let s = initialState(); - s = foldEvent(s, userMessage("")); - expect(selectChunks(s)).toHaveLength(0); - expect(s.generating).toBe(false); - }); - - it("flushes an accumulating chunk before appending the prompt", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - s = foldEvent(s, userMessage("new prompt")); - // the partial assistant text was flushed to provisional, then the user prompt appended - expect(s.accumulating).toBeNull(); - const roles = selectChunks(s).map((c) => c.role); - expect(roles).toEqual(["assistant", "user"]); - }); + const userMessage = (text: string): TurnInputEvent => ({ + type: "user-message", + conversationId: "c1", + turnId: "t1", + text, + }); + + it("a watcher renders the prompt: appends a provisional user chunk + marks generating", () => { + let s = initialState(); + s = foldEvent(s, userMessage("what is 2+2?")); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what is 2+2?" }); + expect(chunks[0]?.provisional).toBe(true); + expect(s.generating).toBe(true); + }); + + it("dedups the SENDER's optimistic echo (no duplicate user bubble)", () => { + let s = initialState(); + s = appendUserMessage(s, "hi"); // optimistic echo from the sender's send() + s = foldEvent(s, userMessage("hi")); // server echo for the same turn + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(1); + }); + + it("appends when the trailing provisional differs (no false dedup)", () => { + let s = initialState(); + s = appendUserMessage(s, "first"); + s = foldEvent(s, userMessage("second")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(2); + }); + + it("ignores an empty user-message", () => { + let s = initialState(); + s = foldEvent(s, userMessage("")); + expect(selectChunks(s)).toHaveLength(0); + expect(s.generating).toBe(false); + }); + + it("flushes an accumulating chunk before appending the prompt", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + s = foldEvent(s, userMessage("new prompt")); + // the partial assistant text was flushed to provisional, then the user prompt appended + expect(s.accumulating).toBeNull(); + const roles = selectChunks(s).map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + }); }); describe("foldEvent — steering (mid-turn steering injection)", () => { - const steering = (text: string): TurnSteeringEvent => ({ - type: "steering", - conversationId: "c1", - turnId: "t1", - text, - }); - - it("appends a provisional user bubble + keeps generating", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, toolResult("t1", "tc1", "read", "output")); - s = foldEvent(s, steering("actually, use a different file")); - const chunks = selectChunks(s); - const last = chunks[chunks.length - 1]; - expect(last?.role).toBe("user"); - expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" }); - expect(last?.provisional).toBe(true); - expect(s.generating).toBe(true); - }); - - it("does NOT dedup against the sender's queue (unlike user-message)", () => { - // The sender enqueued the message via `chat.queue` — the queue SURFACE - // showed it. The `steering` event places it in the transcript; the surface - // separately clears on drain. No de-dup here (the transcript never showed - // the queued message). - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, steering("steer once")); - s = foldEvent(s, steering("steer again")); - const users = selectChunks(s).filter((c) => c.role === "user"); - expect(users).toHaveLength(2); - }); - - it("ignores an empty steering event", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, steering("")); - expect(selectChunks(s)).toHaveLength(0); - expect(s.generating).toBe(true); // turn-start already set it - }); - - it("flushes an accumulating chunk before appending the steering bubble", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial response")); - s = foldEvent(s, steering("mid-turn correction")); - expect(s.accumulating).toBeNull(); - const roles = selectChunks(s).map((c) => c.role); - expect(roles).toEqual(["assistant", "user"]); - }); + const steering = (text: string): TurnSteeringEvent => ({ + type: "steering", + conversationId: "c1", + turnId: "t1", + text, + }); + + it("appends a provisional user bubble + keeps generating", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, toolResult("t1", "tc1", "read", "output")); + s = foldEvent(s, steering("actually, use a different file")); + const chunks = selectChunks(s); + const last = chunks[chunks.length - 1]; + expect(last?.role).toBe("user"); + expect(last?.chunk).toEqual({ type: "text", text: "actually, use a different file" }); + expect(last?.provisional).toBe(true); + expect(s.generating).toBe(true); + }); + + it("does NOT dedup against the sender's queue (unlike user-message)", () => { + // The sender enqueued the message via `chat.queue` — the queue SURFACE + // showed it. The `steering` event places it in the transcript; the surface + // separately clears on drain. No de-dup here (the transcript never showed + // the queued message). + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, steering("steer once")); + s = foldEvent(s, steering("steer again")); + const users = selectChunks(s).filter((c) => c.role === "user"); + expect(users).toHaveLength(2); + }); + + it("ignores an empty steering event", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, steering("")); + expect(selectChunks(s)).toHaveLength(0); + expect(s.generating).toBe(true); // turn-start already set it + }); + + it("flushes an accumulating chunk before appending the steering bubble", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial response")); + s = foldEvent(s, steering("mid-turn correction")); + expect(s.accumulating).toBeNull(); + const roles = selectChunks(s).map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + }); }); describe("applyHistory", () => { - it("orders committed chunks by seq", () => { - const s = initialState(); - const chunks = [ - storedChunk(3, "assistant", { type: "text", text: "c" }), - storedChunk(1, "user", { type: "text", text: "a" }), - storedChunk(2, "assistant", { type: "text", text: "b" }), - ]; - const next = applyHistory(s, chunks); - expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]); - }); - - it("is idempotent on duplicate seqs", () => { - let s = initialState(); - const batch1 = [ - storedChunk(1, "user", { type: "text", text: "a" }), - storedChunk(2, "assistant", { type: "text", text: "b" }), - ]; - s = applyHistory(s, batch1); - const batch2 = [ - storedChunk(2, "assistant", { type: "text", text: "b" }), - storedChunk(3, "assistant", { type: "text", text: "c" }), - ]; - s = applyHistory(s, batch2); - expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]); - expect(s.committed).toHaveLength(3); - }); - - it("supersedes & clears provisional once committed", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello")); - s = foldEvent(s, turnSealed("t1")); - expect(s.provisional).toHaveLength(1); - expect(s.sealedTurnId).toBe("t1"); - - s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]); - expect(s.provisional).toEqual([]); - expect(s.accumulating).toBeNull(); - expect(s.sealedTurnId).toBeNull(); - expect(s.committed).toHaveLength(1); - }); - - it("keeps provisional and accumulating when sealedTurnId is null", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "wip")); - s = foldEvent(s, doneEvent("t1")); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); - expect(s.provisional).toHaveLength(1); - expect(s.committed).toHaveLength(1); - }); - - it("merges new history into existing committed", () => { - let s = initialState(); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]); - s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]); - expect(s.committed).toHaveLength(2); - expect(s.committed.map((c) => c.seq)).toEqual([1, 2]); - }); - - it("removes provisional duplicate when committed user message arrives during generation", () => { - // Simulate: send() appends provisional user message, then syncTail - // fetches the same message as committed (CR-6: persisted at turn start). - let s = initialState(); - s = appendUserMessage(s, "hello"); - s = foldEvent(s, turnStart("t1")); - expect(s.provisional).toHaveLength(1); - expect(s.provisional[0]?.role).toBe("user"); - expect(s.generating).toBe(true); - - // syncTail fetches the persisted user message as committed - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]); - - // The provisional duplicate is removed — no double render - expect(s.provisional).toEqual([]); - expect(s.committed).toHaveLength(1); - expect(s.committed[0]?.role).toBe("user"); - expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" }); - }); + it("orders committed chunks by seq", () => { + const s = initialState(); + const chunks = [ + storedChunk(3, "assistant", { type: "text", text: "c" }), + storedChunk(1, "user", { type: "text", text: "a" }), + storedChunk(2, "assistant", { type: "text", text: "b" }), + ]; + const next = applyHistory(s, chunks); + expect(next.committed.map((c) => c.seq)).toEqual([1, 2, 3]); + }); + + it("is idempotent on duplicate seqs", () => { + let s = initialState(); + const batch1 = [ + storedChunk(1, "user", { type: "text", text: "a" }), + storedChunk(2, "assistant", { type: "text", text: "b" }), + ]; + s = applyHistory(s, batch1); + const batch2 = [ + storedChunk(2, "assistant", { type: "text", text: "b" }), + storedChunk(3, "assistant", { type: "text", text: "c" }), + ]; + s = applyHistory(s, batch2); + expect(s.committed.map((c) => c.seq)).toEqual([1, 2, 3]); + expect(s.committed).toHaveLength(3); + }); + + it("supersedes & clears provisional once committed", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello")); + s = foldEvent(s, turnSealed("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.sealedTurnId).toBe("t1"); + + s = applyHistory(s, [storedChunk(1, "assistant", { type: "text", text: "hello" })]); + expect(s.provisional).toEqual([]); + expect(s.accumulating).toBeNull(); + expect(s.sealedTurnId).toBeNull(); + expect(s.committed).toHaveLength(1); + }); + + it("keeps provisional and accumulating when sealedTurnId is null", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "wip")); + s = foldEvent(s, doneEvent("t1")); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + expect(s.provisional).toHaveLength(1); + expect(s.committed).toHaveLength(1); + }); + + it("merges new history into existing committed", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "a" })]); + s = applyHistory(s, [storedChunk(2, "assistant", { type: "text", text: "b" })]); + expect(s.committed).toHaveLength(2); + expect(s.committed.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("removes provisional duplicate when committed user message arrives during generation", () => { + // Simulate: send() appends provisional user message, then syncTail + // fetches the same message as committed (CR-6: persisted at turn start). + let s = initialState(); + s = appendUserMessage(s, "hello"); + s = foldEvent(s, turnStart("t1")); + expect(s.provisional).toHaveLength(1); + expect(s.provisional[0]?.role).toBe("user"); + expect(s.generating).toBe(true); + + // syncTail fetches the persisted user message as committed + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "hello" })]); + + // The provisional duplicate is removed — no double render + expect(s.provisional).toEqual([]); + expect(s.committed).toHaveLength(1); + expect(s.committed[0]?.role).toBe("user"); + expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hello" }); + }); }); describe("selectChunks", () => { - it("selectChunks marks provisional with seq null", () => { - let s = initialState(); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "wip")); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(2); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[0]?.provisional).toBe(false); - expect(chunks[1]?.seq).toBeNull(); - expect(chunks[1]?.provisional).toBe(true); - }); - - it("returns empty for empty state", () => { - expect(selectChunks(initialState())).toEqual([]); - }); - - it("includes accumulating chunk as provisional", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "building...")); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.seq).toBeNull(); - expect(chunks[0]?.provisional).toBe(true); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." }); - }); - - it("marks ONLY the actively-accumulating chunk as streaming", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - // A flushed-but-still-provisional thinking chunk, then a live accumulating one. - s = foldEvent(s, reasoningDelta("t1", "first thought")); - s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking - s = foldEvent(s, textDelta("t1", "now writing")); - const chunks = selectChunks(s); - const thinking = chunks.find((c) => c.chunk.type === "thinking"); - const accumulating = chunks.find((c) => c.streaming === true); - expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming - expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" }); - expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1); - }); + it("selectChunks marks provisional with seq null", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "wip")); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.provisional).toBe(false); + expect(chunks[1]?.seq).toBeNull(); + expect(chunks[1]?.provisional).toBe(true); + }); + + it("returns empty for empty state", () => { + expect(selectChunks(initialState())).toEqual([]); + }); + + it("includes accumulating chunk as provisional", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "building...")); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBeNull(); + expect(chunks[0]?.provisional).toBe(true); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "building..." }); + }); + + it("marks ONLY the actively-accumulating chunk as streaming", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + // A flushed-but-still-provisional thinking chunk, then a live accumulating one. + s = foldEvent(s, reasoningDelta("t1", "first thought")); + s = foldEvent(s, toolCall("t1", "tc1", "bash", {})); // flushes the thinking + s = foldEvent(s, textDelta("t1", "now writing")); + const chunks = selectChunks(s); + const thinking = chunks.find((c) => c.chunk.type === "thinking"); + const accumulating = chunks.find((c) => c.streaming === true); + expect(thinking?.streaming).toBeFalsy(); // flushed → not streaming + expect(accumulating?.chunk).toEqual({ type: "text", text: "now writing" }); + expect(chunks.filter((c) => c.streaming === true)).toHaveLength(1); + }); }); describe("selectMessages", () => { - it("selectMessages groups consecutive same-role chunks", () => { - let s = initialState(); - s = applyHistory(s, [ - storedChunk(1, "user", { type: "text", text: "q1" }), - storedChunk(2, "user", { type: "text", text: "q2" }), - storedChunk(3, "assistant", { type: "text", text: "a1" }), - storedChunk(4, "assistant", { type: "text", text: "a2" }), - storedChunk(5, "user", { type: "text", text: "q3" }), - ]); - const msgs = selectMessages(s); - expect(msgs).toHaveLength(3); - expect(msgs[0]?.role).toBe("user"); - expect(msgs[0]?.chunks).toHaveLength(2); - expect(msgs[1]?.role).toBe("assistant"); - expect(msgs[1]?.chunks).toHaveLength(2); - expect(msgs[2]?.role).toBe("user"); - expect(msgs[2]?.chunks).toHaveLength(1); - }); - - it("returns empty for empty state", () => { - expect(selectMessages(initialState())).toEqual([]); - }); - - it("mixes committed and provisional in messages", () => { - let s = initialState(); - s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "a1")); - s = foldEvent(s, textDelta("t1", "a2")); - const msgs = selectMessages(s); - expect(msgs).toHaveLength(2); - expect(msgs[0]?.role).toBe("user"); - expect(msgs[0]?.chunks).toHaveLength(1); - expect(msgs[1]?.role).toBe("assistant"); - expect(msgs[1]?.chunks).toHaveLength(1); - expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" }); - }); + it("selectMessages groups consecutive same-role chunks", () => { + let s = initialState(); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "q1" }), + storedChunk(2, "user", { type: "text", text: "q2" }), + storedChunk(3, "assistant", { type: "text", text: "a1" }), + storedChunk(4, "assistant", { type: "text", text: "a2" }), + storedChunk(5, "user", { type: "text", text: "q3" }), + ]); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(3); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(2); + expect(msgs[1]?.role).toBe("assistant"); + expect(msgs[1]?.chunks).toHaveLength(2); + expect(msgs[2]?.role).toBe("user"); + expect(msgs[2]?.chunks).toHaveLength(1); + }); + + it("returns empty for empty state", () => { + expect(selectMessages(initialState())).toEqual([]); + }); + + it("mixes committed and provisional in messages", () => { + let s = initialState(); + s = applyHistory(s, [storedChunk(1, "user", { type: "text", text: "q" })]); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "a1")); + s = foldEvent(s, textDelta("t1", "a2")); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(2); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(1); + expect(msgs[1]?.role).toBe("assistant"); + expect(msgs[1]?.chunks).toHaveLength(1); + expect(msgs[1]?.chunks[0]).toEqual({ type: "text", text: "a1a2" }); + }); }); describe("appendUserMessage", () => { - it("adds a provisional user text chunk", () => { - let s = initialState(); - s = appendUserMessage(s, "hello from user"); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(1); - expect(chunks[0]?.seq).toBeNull(); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" }); - expect(chunks[0]?.provisional).toBe(true); - }); - - it("selectMessages includes the optimistic user message", () => { - let s = initialState(); - s = appendUserMessage(s, "what is 2+2?"); - const msgs = selectMessages(s); - expect(msgs).toHaveLength(1); - expect(msgs[0]?.role).toBe("user"); - expect(msgs[0]?.chunks).toHaveLength(1); - expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" }); - }); - - it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => { - let s = initialState(); - s = appendUserMessage(s, "hi"); - expect(selectChunks(s)).toHaveLength(1); - - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "hello back")); - s = foldEvent(s, turnSealed("t1")); - s = applyHistory(s, [ - storedChunk(1, "user", { type: "text", text: "hi" }), - storedChunk(2, "assistant", { type: "text", text: "hello back" }), - ]); - const chunks = selectChunks(s); - expect(chunks).toHaveLength(2); - expect(chunks[0]?.seq).toBe(1); - expect(chunks[0]?.role).toBe("user"); - expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" }); - expect(chunks[0]?.provisional).toBe(false); - expect(chunks[1]?.seq).toBe(2); - expect(chunks[1]?.role).toBe("assistant"); - expect(chunks[1]?.provisional).toBe(false); - }); - - it("flushes accumulating chunk before appending user message", () => { - let s = initialState(); - s = foldEvent(s, turnStart("t1")); - s = foldEvent(s, textDelta("t1", "partial")); - expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); - - s = appendUserMessage(s, "user msg"); - expect(s.accumulating).toBeNull(); - expect(s.provisional).toHaveLength(2); - expect(s.provisional[0]?.role).toBe("assistant"); - expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" }); - expect(s.provisional[1]?.role).toBe("user"); - expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" }); - }); + it("adds a provisional user text chunk", () => { + let s = initialState(); + s = appendUserMessage(s, "hello from user"); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(1); + expect(chunks[0]?.seq).toBeNull(); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hello from user" }); + expect(chunks[0]?.provisional).toBe(true); + }); + + it("selectMessages includes the optimistic user message", () => { + let s = initialState(); + s = appendUserMessage(s, "what is 2+2?"); + const msgs = selectMessages(s); + expect(msgs).toHaveLength(1); + expect(msgs[0]?.role).toBe("user"); + expect(msgs[0]?.chunks).toHaveLength(1); + expect(msgs[0]?.chunks[0]).toEqual({ type: "text", text: "what is 2+2?" }); + }); + + it("user echo then turn-sealed + applyHistory supersedes the provisional user chunk", () => { + let s = initialState(); + s = appendUserMessage(s, "hi"); + expect(selectChunks(s)).toHaveLength(1); + + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "hello back")); + s = foldEvent(s, turnSealed("t1")); + s = applyHistory(s, [ + storedChunk(1, "user", { type: "text", text: "hi" }), + storedChunk(2, "assistant", { type: "text", text: "hello back" }), + ]); + const chunks = selectChunks(s); + expect(chunks).toHaveLength(2); + expect(chunks[0]?.seq).toBe(1); + expect(chunks[0]?.role).toBe("user"); + expect(chunks[0]?.chunk).toEqual({ type: "text", text: "hi" }); + expect(chunks[0]?.provisional).toBe(false); + expect(chunks[1]?.seq).toBe(2); + expect(chunks[1]?.role).toBe("assistant"); + expect(chunks[1]?.provisional).toBe(false); + }); + + it("flushes accumulating chunk before appending user message", () => { + let s = initialState(); + s = foldEvent(s, turnStart("t1")); + s = foldEvent(s, textDelta("t1", "partial")); + expect(s.accumulating).toEqual({ kind: "text", text: "partial" }); + + s = appendUserMessage(s, "user msg"); + expect(s.accumulating).toBeNull(); + expect(s.provisional).toHaveLength(2); + expect(s.provisional[0]?.role).toBe("assistant"); + expect(s.provisional[0]?.chunk).toEqual({ type: "text", text: "partial" }); + expect(s.provisional[1]?.role).toBe("user"); + expect(s.provisional[1]?.chunk).toEqual({ type: "text", text: "user msg" }); + }); }); diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts index 1e9c8f5..2152de3 100644 --- a/src/core/chunks/reducer.ts +++ b/src/core/chunks/reducer.ts @@ -3,18 +3,18 @@ import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./typ /** The initial empty transcript state. */ export function initialState(): TranscriptState { - return { - committed: [], - provisional: [], - accumulating: null, - currentTurnId: null, - latestUsage: null, - sealedTurnId: null, - hiddenBeforeSeq: 0, - hiddenThinkingCount: 0, - generating: false, - providerRetry: null, - }; + return { + committed: [], + provisional: [], + accumulating: null, + currentTurnId: null, + latestUsage: null, + sealedTurnId: null, + hiddenBeforeSeq: 0, + hiddenThinkingCount: 0, + generating: false, + providerRetry: null, + }; } /** @@ -25,21 +25,21 @@ export function initialState(): TranscriptState { * server's replay re-asserts `generating` via the replayed `turn-start`. */ export function clearGenerating(state: TranscriptState): TranscriptState { - if (!state.generating) return state; - // Also drop a stale `provider-retry` banner — a retry pending at disconnect - // is stale once we re-subscribe (provider-retry events are not replayed), so - // a finished turn must not keep showing a "retrying…" banner forever. - return { ...state, generating: false, providerRetry: null }; + if (!state.generating) return state; + // Also drop a stale `provider-retry` banner — a retry pending at disconnect + // is stale once we re-subscribe (provider-retry events are not replayed), so + // a finished turn must not keep showing a "retrying…" banner forever. + return { ...state, generating: false, providerRetry: null }; } function flushAccumulating( - provisional: readonly ProvisionalChunk[], - acc: AccumulatingChunk | null, + provisional: readonly ProvisionalChunk[], + acc: AccumulatingChunk | null, ): readonly ProvisionalChunk[] { - if (acc === null) return provisional; - const chunk: Chunk = - acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; - return [...provisional, { role: "assistant", chunk }]; + if (acc === null) return provisional; + const chunk: Chunk = + acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text }; + return [...provisional, { role: "assistant", chunk }]; } /** @@ -53,50 +53,50 @@ function flushAccumulating( * unloaded. Restoring earlier history goes through `restoreEarlier` instead. */ export function applyHistory( - state: TranscriptState, - chunks: readonly StoredChunk[], + state: TranscriptState, + chunks: readonly StoredChunk[], ): TranscriptState { - const seqMap = new Map(); - for (const c of state.committed) seqMap.set(c.seq, c); - let addedNew = false; - for (const c of chunks) { - if (c.seq < state.hiddenBeforeSeq) continue; - if (!seqMap.has(c.seq)) addedNew = true; - seqMap.set(c.seq, c); - } - const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq); + const seqMap = new Map(); + for (const c of state.committed) seqMap.set(c.seq, c); + let addedNew = false; + for (const c of chunks) { + if (c.seq < state.hiddenBeforeSeq) continue; + if (!seqMap.has(c.seq)) addedNew = true; + seqMap.set(c.seq, c); + } + const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq); - if (state.sealedTurnId !== null) { - return { - ...state, - committed, - provisional: [], - accumulating: null, - sealedTurnId: null, - }; - } + if (state.sealedTurnId !== null) { + return { + ...state, + committed, + provisional: [], + accumulating: null, + sealedTurnId: null, + }; + } - // During generation: if new committed chunks arrived, the provisional - // array may contain duplicates — the optimistic echo from `appendUserMessage` - // is now backed by a committed chunk (CR-6: user message persisted at turn - // start). Remove provisional chunks that match the last committed chunk - // (role + chunk content), keeping only the accumulating (streaming) chunk. - if (addedNew && state.generating && state.provisional.length > 0) { - const lastCommitted = committed[committed.length - 1]; - if (lastCommitted !== undefined) { - const provisional = state.provisional.filter((p) => { - if (p.role !== lastCommitted.role) return true; - if (p.chunk.type !== lastCommitted.chunk.type) return true; - if (p.chunk.type === "text" && lastCommitted.chunk.type === "text") { - return p.chunk.text !== lastCommitted.chunk.text; - } - return true; - }); - return { ...state, committed, provisional, accumulating: state.accumulating }; - } - } + // During generation: if new committed chunks arrived, the provisional + // array may contain duplicates — the optimistic echo from `appendUserMessage` + // is now backed by a committed chunk (CR-6: user message persisted at turn + // start). Remove provisional chunks that match the last committed chunk + // (role + chunk content), keeping only the accumulating (streaming) chunk. + if (addedNew && state.generating && state.provisional.length > 0) { + const lastCommitted = committed[committed.length - 1]; + if (lastCommitted !== undefined) { + const provisional = state.provisional.filter((p) => { + if (p.role !== lastCommitted.role) return true; + if (p.chunk.type !== lastCommitted.chunk.type) return true; + if (p.chunk.type === "text" && lastCommitted.chunk.type === "text") { + return p.chunk.text !== lastCommitted.chunk.text; + } + return true; + }); + return { ...state, committed, provisional, accumulating: state.accumulating }; + } + } - return { ...state, committed }; + return { ...state, committed }; } /** @@ -126,179 +126,179 @@ export function applyHistory( * below, so the clearing logic stays centralized in one place. */ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState { - switch (event.type) { - case "status": - case "tool-output": - return state; + switch (event.type) { + case "status": + case "tool-output": + return state; - case "turn-start": - return { ...state, currentTurnId: event.turnId, generating: true }; + case "turn-start": + return { ...state, currentTurnId: event.turnId, generating: true }; - case "user-message": { - // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a - // WATCHER/late-joiner renders it mid-turn instead of waiting for seal. The - // SENDER already echoed its own prompt optimistically (`appendUserMessage`), - // so DE-DUP: skip if the trailing provisional chunk is already an identical - // user text chunk. A pure watcher has no such echo → it appends and renders. - if (event.text.length === 0) return state; - const last = state.provisional[state.provisional.length - 1]; - if ( - last !== undefined && - last.role === "user" && - last.chunk.type === "text" && - last.chunk.text === event.text - ) { - return { ...state, generating: true }; - } - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], - accumulating: null, - generating: true, - }; - } + case "user-message": { + // The turn's USER prompt, surfaced on the event stream (backend CR-3) so a + // WATCHER/late-joiner renders it mid-turn instead of waiting for seal. The + // SENDER already echoed its own prompt optimistically (`appendUserMessage`), + // so DE-DUP: skip if the trailing provisional chunk is already an identical + // user text chunk. A pure watcher has no such echo → it appends and renders. + if (event.text.length === 0) return state; + const last = state.provisional[state.provisional.length - 1]; + if ( + last !== undefined && + last.role === "user" && + last.chunk.type === "text" && + last.chunk.text === event.text + ) { + return { ...state, generating: true }; + } + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + accumulating: null, + generating: true, + }; + } - case "text-delta": { - const acc = state.accumulating; - if (acc !== null && acc.kind === "text") { - return { - ...state, - accumulating: { kind: "text", text: acc.text + event.delta }, - generating: true, - }; - } - const provisional = flushAccumulating(state.provisional, acc); - return { - ...state, - provisional, - accumulating: { kind: "text", text: event.delta }, - generating: true, - }; - } + case "text-delta": { + const acc = state.accumulating; + if (acc !== null && acc.kind === "text") { + return { + ...state, + accumulating: { kind: "text", text: acc.text + event.delta }, + generating: true, + }; + } + const provisional = flushAccumulating(state.provisional, acc); + return { + ...state, + provisional, + accumulating: { kind: "text", text: event.delta }, + generating: true, + }; + } - case "reasoning-delta": { - const acc = state.accumulating; - if (acc !== null && acc.kind === "thinking") { - return { - ...state, - accumulating: { kind: "thinking", text: acc.text + event.delta }, - generating: true, - }; - } - const provisional = flushAccumulating(state.provisional, acc); - return { - ...state, - provisional, - accumulating: { kind: "thinking", text: event.delta }, - generating: true, - }; - } + case "reasoning-delta": { + const acc = state.accumulating; + if (acc !== null && acc.kind === "thinking") { + return { + ...state, + accumulating: { kind: "thinking", text: acc.text + event.delta }, + generating: true, + }; + } + const provisional = flushAccumulating(state.provisional, acc); + return { + ...state, + provisional, + accumulating: { kind: "thinking", text: event.delta }, + generating: true, + }; + } - case "tool-call": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const chunk: Chunk = { - type: "tool-call", - toolCallId: event.toolCallId, - toolName: event.toolName, - input: event.input, - stepId: event.stepId, - }; - return { - ...state, - provisional: [...provisional, { role: "assistant", chunk }], - accumulating: null, - generating: true, - }; - } + case "tool-call": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const chunk: Chunk = { + type: "tool-call", + toolCallId: event.toolCallId, + toolName: event.toolName, + input: event.input, + stepId: event.stepId, + }; + return { + ...state, + provisional: [...provisional, { role: "assistant", chunk }], + accumulating: null, + generating: true, + }; + } - case "tool-result": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const chunk: Chunk = { - type: "tool-result", - toolCallId: event.toolCallId, - toolName: event.toolName, - content: event.content, - isError: event.isError, - stepId: event.stepId, - }; - return { - ...state, - provisional: [...provisional, { role: "tool", chunk }], - accumulating: null, - generating: true, - }; - } + case "tool-result": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const chunk: Chunk = { + type: "tool-result", + toolCallId: event.toolCallId, + toolName: event.toolName, + content: event.content, + isError: event.isError, + stepId: event.stepId, + }; + return { + ...state, + provisional: [...provisional, { role: "tool", chunk }], + accumulating: null, + generating: true, + }; + } - case "error": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const chunk: Chunk = - event.code !== undefined - ? { type: "error", message: event.message, code: event.code } - : { type: "error", message: event.message }; - return { - ...state, - provisional: [...provisional, { role: "assistant", chunk }], - accumulating: null, - generating: false, - }; - } + case "error": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + const chunk: Chunk = + event.code !== undefined + ? { type: "error", message: event.message, code: event.code } + : { type: "error", message: event.message }; + return { + ...state, + provisional: [...provisional, { role: "assistant", chunk }], + accumulating: null, + generating: false, + }; + } - case "usage": - return { ...state, latestUsage: event.usage }; + case "usage": + return { ...state, latestUsage: event.usage }; - case "step-complete": - // Timing metadata — no content chunk; handled by the telemetry reducer. - return state; + case "step-complete": + // Timing metadata — no content chunk; handled by the telemetry reducer. + return state; - case "done": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional, - accumulating: null, - generating: false, - }; - } + case "done": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional, + accumulating: null, + generating: false, + }; + } - case "turn-sealed": { - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional, - accumulating: null, - sealedTurnId: event.turnId, - generating: false, - }; - } + case "turn-sealed": { + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional, + accumulating: null, + sealedTurnId: event.turnId, + generating: false, + }; + } - case "steering": { - // A steering message drained from the queue at a tool-result boundary - // (the model sees it alongside the tool results). Append a user bubble - // to the provisional transcript; the turn is still in flight. The queue - // surface clears separately on drain (a different channel) — no de-dup - // here (unlike `user-message`, steering is never optimistically echoed - // into the transcript by the sender). - if (event.text.length === 0) return state; - const provisional = flushAccumulating(state.provisional, state.accumulating); - return { - ...state, - provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], - accumulating: null, - generating: true, - }; - } + case "steering": { + // A steering message drained from the queue at a tool-result boundary + // (the model sees it alongside the tool results). Append a user bubble + // to the provisional transcript; the turn is still in flight. The queue + // surface clears separately on drain (a different channel) — no de-dup + // here (unlike `user-message`, steering is never optimistically echoed + // into the transcript by the sender). + if (event.text.length === 0) return state; + const provisional = flushAccumulating(state.provisional, state.accumulating); + return { + ...state, + provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }], + accumulating: null, + generating: true, + }; + } - case "provider-retry": { - // TRANSIENT: a retryable provider error is being retried with backoff. - // Coalesce — the latest attempt + delay replaces any previous, so a - // single updating "retrying…" banner shows the newest. NOT a chunk: it - // never enters provisional/committed, so it can never pollute the prompt - // or be replayed on a reload. The turn is still in flight, so `generating` - // (already true from `turn-start`) is left untouched. - return { ...state, providerRetry: event }; - } - } + case "provider-retry": { + // TRANSIENT: a retryable provider error is being retried with backoff. + // Coalesce — the latest attempt + delay replaces any previous, so a + // single updating "retrying…" banner shows the newest. NOT a chunk: it + // never enters provisional/committed, so it can never pollute the prompt + // or be replayed on a reload. The turn is still in flight, so `generating` + // (already true from `turn-start`) is left untouched. + return { ...state, providerRetry: event }; + } + } } /** @@ -313,23 +313,23 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState */ // Events that clear a showing provider-retry banner (content resumed or turn ended). const RETRY_CLEARING_EVENTS: ReadonlySet = new Set([ - "turn-start", - "text-delta", - "reasoning-delta", - "tool-call", - "tool-result", - "error", - "done", - "turn-sealed", + "turn-start", + "text-delta", + "reasoning-delta", + "tool-call", + "tool-result", + "error", + "done", + "turn-sealed", ]); export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState { - const next = reduceEvent(state, event); - if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event - if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) { - return { ...next, providerRetry: null }; - } - return next; + const next = reduceEvent(state, event); + if (event.type === "provider-retry") return next; // set by reduceEvent; not a clearing event + if (RETRY_CLEARING_EVENTS.has(event.type) && state.providerRetry !== null) { + return { ...next, providerRetry: null }; + } + return next; } /** @@ -339,11 +339,11 @@ export function foldEvent(state: TranscriptState, event: AgentEvent): Transcript * the authoritative committed chunks after a turn seals. */ export function appendUserMessage(state: TranscriptState, text: string): TranscriptState { - const provisional = flushAccumulating(state.provisional, state.accumulating); - const userChunk: Chunk = { type: "text", text }; - return { - ...state, - provisional: [...provisional, { role: "user", chunk: userChunk }], - accumulating: null, - }; + const provisional = flushAccumulating(state.provisional, state.accumulating); + const userChunk: Chunk = { type: "text", text }; + return { + ...state, + provisional: [...provisional, { role: "user", chunk: userChunk }], + accumulating: null, + }; } diff --git a/src/core/chunks/retry-banner.test.ts b/src/core/chunks/retry-banner.test.ts index ecc2232..1d8c1cd 100644 --- a/src/core/chunks/retry-banner.test.ts +++ b/src/core/chunks/retry-banner.test.ts @@ -3,61 +3,61 @@ import { describe, expect, it } from "vitest"; import { formatRetryDelay, viewProviderRetry } from "./retry-banner"; const retry = ( - attempt: number, - delayMs: number, - message = "HTTP 429: overloaded", - code?: string, + attempt: number, + delayMs: number, + message = "HTTP 429: overloaded", + code?: string, ): TurnProviderRetryEvent => - code !== undefined - ? { - type: "provider-retry", - conversationId: "c1", - turnId: "t1", - attempt, - delayMs, - message, - code, - } - : { type: "provider-retry", conversationId: "c1", turnId: "t1", attempt, delayMs, message }; + code !== undefined + ? { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt, + delayMs, + message, + code, + } + : { type: "provider-retry", conversationId: "c1", turnId: "t1", attempt, delayMs, message }; describe("formatRetryDelay", () => { - it("formats sub-minute delays as seconds", () => { - expect(formatRetryDelay(5000)).toBe("5s"); - expect(formatRetryDelay(10000)).toBe("10s"); - expect(formatRetryDelay(30000)).toBe("30s"); - }); - - it("formats minute+ delays as minutes", () => { - expect(formatRetryDelay(60000)).toBe("1m"); - expect(formatRetryDelay(300000)).toBe("5m"); - expect(formatRetryDelay(1800000)).toBe("30m"); - }); - - it("rounds to the nearest whole unit", () => { - expect(formatRetryDelay(5500)).toBe("6s"); // 5.5s -> 6s - expect(formatRetryDelay(90000)).toBe("2m"); // 1.5m -> 2m - }); + it("formats sub-minute delays as seconds", () => { + expect(formatRetryDelay(5000)).toBe("5s"); + expect(formatRetryDelay(10000)).toBe("10s"); + expect(formatRetryDelay(30000)).toBe("30s"); + }); + + it("formats minute+ delays as minutes", () => { + expect(formatRetryDelay(60000)).toBe("1m"); + expect(formatRetryDelay(300000)).toBe("5m"); + expect(formatRetryDelay(1800000)).toBe("30m"); + }); + + it("rounds to the nearest whole unit", () => { + expect(formatRetryDelay(5500)).toBe("6s"); // 5.5s -> 6s + expect(formatRetryDelay(90000)).toBe("2m"); // 1.5m -> 2m + }); }); describe("viewProviderRetry", () => { - it("labels the attempt 1-based (attempt 0 = Retry #1)", () => { - expect(viewProviderRetry(retry(0, 5000)).attemptLabel).toBe("Retry #1"); - expect(viewProviderRetry(retry(1, 10000)).attemptLabel).toBe("Retry #2"); - expect(viewProviderRetry(retry(7, 1800000)).attemptLabel).toBe("Retry #8"); - }); - - it("derives the delay label from delayMs", () => { - expect(viewProviderRetry(retry(0, 5000)).delayLabel).toBe("5s"); - expect(viewProviderRetry(retry(4, 300000)).delayLabel).toBe("5m"); - }); - - it("passes the endpoint error verbatim", () => { - const msg = 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}'; - expect(viewProviderRetry(retry(0, 5000, msg)).message).toBe(msg); - }); - - it("surfaces the code when present, null when absent", () => { - expect(viewProviderRetry(retry(0, 5000, "msg", "429")).code).toBe("429"); - expect(viewProviderRetry(retry(0, 5000, "msg")).code).toBeNull(); - }); + it("labels the attempt 1-based (attempt 0 = Retry #1)", () => { + expect(viewProviderRetry(retry(0, 5000)).attemptLabel).toBe("Retry #1"); + expect(viewProviderRetry(retry(1, 10000)).attemptLabel).toBe("Retry #2"); + expect(viewProviderRetry(retry(7, 1800000)).attemptLabel).toBe("Retry #8"); + }); + + it("derives the delay label from delayMs", () => { + expect(viewProviderRetry(retry(0, 5000)).delayLabel).toBe("5s"); + expect(viewProviderRetry(retry(4, 300000)).delayLabel).toBe("5m"); + }); + + it("passes the endpoint error verbatim", () => { + const msg = 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}'; + expect(viewProviderRetry(retry(0, 5000, msg)).message).toBe(msg); + }); + + it("surfaces the code when present, null when absent", () => { + expect(viewProviderRetry(retry(0, 5000, "msg", "429")).code).toBe("429"); + expect(viewProviderRetry(retry(0, 5000, "msg")).code).toBeNull(); + }); }); diff --git a/src/core/chunks/retry-banner.ts b/src/core/chunks/retry-banner.ts index a00748a..afa6a98 100644 --- a/src/core/chunks/retry-banner.ts +++ b/src/core/chunks/retry-banner.ts @@ -14,14 +14,14 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; /** The display shape for a provider-retry banner. */ export interface ProviderRetryView { - /** "Retry #N" — `attempt` is 0-based, so +1 (attempt 0 = "Retry #1"). */ - readonly attemptLabel: string; - /** The scheduled sleep as a short duration: "5s" / "30s" / "1m" / "5m" / "30m". */ - readonly delayLabel: string; - /** The endpoint's error verbatim, e.g. "HTTP 429: {…overloaded_error…}". */ - readonly message: string; - /** The HTTP code when known (e.g. "429"), else null. */ - readonly code: string | null; + /** "Retry #N" — `attempt` is 0-based, so +1 (attempt 0 = "Retry #1"). */ + readonly attemptLabel: string; + /** The scheduled sleep as a short duration: "5s" / "30s" / "1m" / "5m" / "30m". */ + readonly delayLabel: string; + /** The endpoint's error verbatim, e.g. "HTTP 429: {…overloaded_error…}". */ + readonly message: string; + /** The HTTP code when known (e.g. "429"), else null. */ + readonly code: string | null; } /** @@ -30,11 +30,11 @@ export interface ProviderRetryView { * shows seconds, under an hour shows minutes, else hours. */ export function formatRetryDelay(ms: number): string { - const totalSeconds = Math.round(ms / 1000); - if (totalSeconds < 60) return `${totalSeconds}s`; - const totalMinutes = Math.round(totalSeconds / 60); - if (totalMinutes < 60) return `${totalMinutes}m`; - return `${Math.round(totalMinutes / 60)}h`; + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const totalMinutes = Math.round(totalSeconds / 60); + if (totalMinutes < 60) return `${totalMinutes}m`; + return `${Math.round(totalMinutes / 60)}h`; } /** @@ -42,10 +42,10 @@ export function formatRetryDelay(ms: number): string { * retry about to happen), so the label is 1-based for the user. */ export function viewProviderRetry(event: TurnProviderRetryEvent): ProviderRetryView { - return { - attemptLabel: `Retry #${event.attempt + 1}`, - delayLabel: formatRetryDelay(event.delayMs), - message: event.message, - code: event.code ?? null, - }; + return { + attemptLabel: `Retry #${event.attempt + 1}`, + delayLabel: formatRetryDelay(event.delayMs), + message: event.message, + code: event.code ?? null, + }; } diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts index 3d91559..e46d5f5 100644 --- a/src/core/chunks/selectors.ts +++ b/src/core/chunks/selectors.ts @@ -6,21 +6,21 @@ import type { RenderedChunk, TranscriptState } from "./types"; * then provisional (seq: null). */ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { - const result: RenderedChunk[] = []; - for (const c of state.committed) { - result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); - } - for (const p of state.provisional) { - result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true }); - } - if (state.accumulating !== null) { - const chunk: Chunk = - state.accumulating.kind === "text" - ? { type: "text", text: state.accumulating.text } - : { type: "thinking", text: state.accumulating.text }; - result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true }); - } - return result; + const result: RenderedChunk[] = []; + for (const c of state.committed) { + result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false }); + } + for (const p of state.provisional) { + result.push({ seq: null, role: p.role, chunk: p.chunk, provisional: true }); + } + if (state.accumulating !== null) { + const chunk: Chunk = + state.accumulating.kind === "text" + ? { type: "text", text: state.accumulating.text } + : { type: "thinking", text: state.accumulating.text }; + result.push({ seq: null, role: "assistant", chunk, provisional: true, streaming: true }); + } + return result; } /** @@ -29,7 +29,7 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] { * reconnected client whose in-flight turn was replayed. */ export function selectGenerating(state: TranscriptState): boolean { - return state.generating; + return state.generating; } /** @@ -38,32 +38,32 @@ export function selectGenerating(state: TranscriptState): boolean { * by ChatView). Never persisted — see `TranscriptState.providerRetry`. */ export function selectProviderRetry(state: TranscriptState): TurnProviderRetryEvent | null { - return state.providerRetry; + return state.providerRetry; } /** * Group consecutive same-role rendered chunks into ChatMessages. */ export function selectMessages(state: TranscriptState): readonly ChatMessage[] { - const rendered = selectChunks(state); - const first = rendered[0]; - if (first === undefined) return []; + const rendered = selectChunks(state); + const first = rendered[0]; + if (first === undefined) return []; - const messages: ChatMessage[] = []; - let role = first.role; - let chunks: Chunk[] = [first.chunk]; + const messages: ChatMessage[] = []; + let role = first.role; + let chunks: Chunk[] = [first.chunk]; - for (let i = 1; i < rendered.length; i++) { - const rc = rendered[i]; - if (rc === undefined) continue; - if (rc.role === role) { - chunks.push(rc.chunk); - } else { - messages.push({ role, chunks }); - role = rc.role; - chunks = [rc.chunk]; - } - } - messages.push({ role, chunks }); - return messages; + for (let i = 1; i < rendered.length; i++) { + const rc = rendered[i]; + if (rc === undefined) continue; + if (rc.role === role) { + chunks.push(rc.chunk); + } else { + messages.push({ role, chunks }); + role = rc.role; + chunks = [rc.chunk]; + } + } + messages.push({ role, chunks }); + return messages; } diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts index aa4b0e3..7c4bbef 100644 --- a/src/core/chunks/trim.test.ts +++ b/src/core/chunks/trim.test.ts @@ -2,234 +2,234 @@ import type { StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { applyHistory, initialState } from "./reducer"; import { - DEFAULT_CHAT_LIMIT, - initialWindowSize, - MAX_CHAT_LIMIT, - MIN_CHAT_LIMIT, - normalizeChatLimit, - restoreEarlier, - selectHasEarlier, - trimTranscript, - unloadCount, - windowTranscript, + DEFAULT_CHAT_LIMIT, + initialWindowSize, + MAX_CHAT_LIMIT, + MIN_CHAT_LIMIT, + normalizeChatLimit, + restoreEarlier, + selectHasEarlier, + trimTranscript, + unloadCount, + windowTranscript, } from "./trim"; import type { TranscriptState } from "./types"; function chunk(seq: number, type: "text" | "thinking" = "text"): StoredChunk { - return { seq, role: "assistant", chunk: { type, text: `c${seq}` } }; + return { seq, role: "assistant", chunk: { type, text: `c${seq}` } }; } function chunks(from: number, to: number): StoredChunk[] { - const out: StoredChunk[] = []; - for (let seq = from; seq <= to; seq++) out.push(chunk(seq)); - return out; + const out: StoredChunk[] = []; + for (let seq = from; seq <= to; seq++) out.push(chunk(seq)); + return out; } function stateWith(committed: readonly StoredChunk[]): TranscriptState { - return { ...initialState(), committed }; + return { ...initialState(), committed }; } describe("normalizeChatLimit", () => { - it("defaults non-numeric / NaN / missing values", () => { - expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT); - expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT); - }); - - it("floors and clamps numeric values", () => { - expect(normalizeChatLimit(100.9)).toBe(100); - expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT); - expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT); - expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT); - expect(normalizeChatLimit(256)).toBe(256); - }); + it("defaults non-numeric / NaN / missing values", () => { + expect(normalizeChatLimit(undefined)).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit(null)).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit("100")).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit(Number.NaN)).toBe(DEFAULT_CHAT_LIMIT); + expect(normalizeChatLimit(Number.POSITIVE_INFINITY)).toBe(DEFAULT_CHAT_LIMIT); + }); + + it("floors and clamps numeric values", () => { + expect(normalizeChatLimit(100.9)).toBe(100); + expect(normalizeChatLimit(0)).toBe(MIN_CHAT_LIMIT); + expect(normalizeChatLimit(-5)).toBe(MIN_CHAT_LIMIT); + expect(normalizeChatLimit(10_000_000)).toBe(MAX_CHAT_LIMIT); + expect(normalizeChatLimit(256)).toBe(256); + }); }); describe("unloadCount / initialWindowSize", () => { - it("unload is a quarter of the limit, rounded up", () => { - expect(unloadCount(100)).toBe(25); - expect(unloadCount(256)).toBe(64); - expect(unloadCount(10)).toBe(3); - }); - - it("initial window is 75% of the limit, rounded down", () => { - expect(initialWindowSize(100)).toBe(75); - expect(initialWindowSize(256)).toBe(192); - expect(initialWindowSize(1)).toBe(1); // never below 1 - }); + it("unload is a quarter of the limit, rounded up", () => { + expect(unloadCount(100)).toBe(25); + expect(unloadCount(256)).toBe(64); + expect(unloadCount(10)).toBe(3); + }); + + it("initial window is 75% of the limit, rounded down", () => { + expect(initialWindowSize(100)).toBe(75); + expect(initialWindowSize(256)).toBe(192); + expect(initialWindowSize(1)).toBe(1); // never below 1 + }); }); describe("trimTranscript", () => { - it("is the identity at or under the limit", () => { - const at = stateWith(chunks(1, 100)); - expect(trimTranscript(at, 100)).toBe(at); - const under = stateWith(chunks(1, 99)); - expect(trimTranscript(under, 100)).toBe(under); - }); - - it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => { - const state = stateWith(chunks(1, 101)); - const next = trimTranscript(state, 100); - expect(next.committed).toHaveLength(76); - expect(next.committed[0]?.seq).toBe(26); - expect(next.hiddenBeforeSeq).toBe(26); - }); - - it("unloads multiple quarters when trimming was deferred far past the limit", () => { - const state = stateWith(chunks(1, 130)); - const next = trimTranscript(state, 100); - // 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain. - expect(next.committed).toHaveLength(80); - expect(next.committed[0]?.seq).toBe(51); - expect(next.hiddenBeforeSeq).toBe(51); - }); - - it("counts provisional + accumulating toward the limit (drops committed first)", () => { - const base = stateWith(chunks(1, 98)); - const state: TranscriptState = { - ...base, - provisional: [ - { role: "user", chunk: { type: "text", text: "q" } }, - { role: "assistant", chunk: { type: "text", text: "a" } }, - ], - accumulating: { kind: "text", text: "stream" }, - }; - // 98 + 2 + 1 = 101 > 100 → drop 25 committed. - const next = trimTranscript(state, 100); - expect(next.committed).toHaveLength(73); - expect(next.provisional).toHaveLength(2); - expect(next.accumulating).not.toBeNull(); - }); - - it("drops oldest provisional when committed is exhausted", () => { - const base = stateWith(chunks(1, 2)); - const provisional = Array.from({ length: 20 }, (_, i) => ({ - role: "assistant" as const, - chunk: { type: "text" as const, text: `p${i}` }, - })); - const state: TranscriptState = { ...base, provisional }; - // 2 + 20 = 22 > 10. quarter = 3. Drop 2 committed, then drop - // ceil((20-10)/3)*3 = 12 provisional → 8 remain. - const next = trimTranscript(state, 10); - expect(next.committed).toHaveLength(0); - expect(next.provisional).toHaveLength(8); - expect(next.hiddenBeforeSeq).toBe(3); - }); - - it("accumulates the hidden thinking count for stable render keys", () => { - const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)]; - const state = stateWith(committed); - const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest - expect(next.committed[0]?.seq).toBe(4); - expect(next.hiddenThinkingCount).toBe(1); - }); - - it("ignores a nonsensical limit", () => { - const state = stateWith(chunks(1, 50)); - expect(trimTranscript(state, 0)).toBe(state); - expect(trimTranscript(state, Number.NaN)).toBe(state); - }); + it("is the identity at or under the limit", () => { + const at = stateWith(chunks(1, 100)); + expect(trimTranscript(at, 100)).toBe(at); + const under = stateWith(chunks(1, 99)); + expect(trimTranscript(under, 100)).toBe(under); + }); + + it("unloads exactly a quarter when the limit is first exceeded (100 → 101 drops 25)", () => { + const state = stateWith(chunks(1, 101)); + const next = trimTranscript(state, 100); + expect(next.committed).toHaveLength(76); + expect(next.committed[0]?.seq).toBe(26); + expect(next.hiddenBeforeSeq).toBe(26); + }); + + it("unloads multiple quarters when trimming was deferred far past the limit", () => { + const state = stateWith(chunks(1, 130)); + const next = trimTranscript(state, 100); + // 130 → needs 2 quarters (25 each) to get to ≤ 100 → 80 remain. + expect(next.committed).toHaveLength(80); + expect(next.committed[0]?.seq).toBe(51); + expect(next.hiddenBeforeSeq).toBe(51); + }); + + it("counts provisional + accumulating toward the limit (drops committed first)", () => { + const base = stateWith(chunks(1, 98)); + const state: TranscriptState = { + ...base, + provisional: [ + { role: "user", chunk: { type: "text", text: "q" } }, + { role: "assistant", chunk: { type: "text", text: "a" } }, + ], + accumulating: { kind: "text", text: "stream" }, + }; + // 98 + 2 + 1 = 101 > 100 → drop 25 committed. + const next = trimTranscript(state, 100); + expect(next.committed).toHaveLength(73); + expect(next.provisional).toHaveLength(2); + expect(next.accumulating).not.toBeNull(); + }); + + it("drops oldest provisional when committed is exhausted", () => { + const base = stateWith(chunks(1, 2)); + const provisional = Array.from({ length: 20 }, (_, i) => ({ + role: "assistant" as const, + chunk: { type: "text" as const, text: `p${i}` }, + })); + const state: TranscriptState = { ...base, provisional }; + // 2 + 20 = 22 > 10. quarter = 3. Drop 2 committed, then drop + // ceil((20-10)/3)*3 = 12 provisional → 8 remain. + const next = trimTranscript(state, 10); + expect(next.committed).toHaveLength(0); + expect(next.provisional).toHaveLength(8); + expect(next.hiddenBeforeSeq).toBe(3); + }); + + it("accumulates the hidden thinking count for stable render keys", () => { + const committed = [chunk(1, "thinking"), ...chunks(2, 9), chunk(10, "thinking"), chunk(11)]; + const state = stateWith(committed); + const next = trimTranscript(state, 10); // 11 > 10 → drop ceil(10/4)=3 oldest + expect(next.committed[0]?.seq).toBe(4); + expect(next.hiddenThinkingCount).toBe(1); + }); + + it("ignores a nonsensical limit", () => { + const state = stateWith(chunks(1, 50)); + expect(trimTranscript(state, 0)).toBe(state); + expect(trimTranscript(state, Number.NaN)).toBe(state); + }); }); describe("windowTranscript", () => { - it("keeps only the newest maxCommitted chunks and sets the watermark", () => { - const state = stateWith(chunks(1, 1000)); - const next = windowTranscript(state, 75); - expect(next.committed).toHaveLength(75); - expect(next.committed[0]?.seq).toBe(926); - expect(next.hiddenBeforeSeq).toBe(926); - expect(selectHasEarlier(next)).toBe(true); - }); - - it("is the identity within the window", () => { - const state = stateWith(chunks(1, 50)); - expect(windowTranscript(state, 75)).toBe(state); - expect(selectHasEarlier(state)).toBe(false); - }); + it("keeps only the newest maxCommitted chunks and sets the watermark", () => { + const state = stateWith(chunks(1, 1000)); + const next = windowTranscript(state, 75); + expect(next.committed).toHaveLength(75); + expect(next.committed[0]?.seq).toBe(926); + expect(next.hiddenBeforeSeq).toBe(926); + expect(selectHasEarlier(next)).toBe(true); + }); + + it("is the identity within the window", () => { + const state = stateWith(chunks(1, 50)); + expect(windowTranscript(state, 75)).toBe(state); + expect(selectHasEarlier(state)).toBe(false); + }); }); describe("applyHistory respects the watermark", () => { - it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => { - const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); - expect(trimmed.hiddenBeforeSeq).toBe(26); - // A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out. - const merged = applyHistory(trimmed, chunks(1, 101)); - expect(merged.committed[0]?.seq).toBe(26); - expect(merged.committed).toHaveLength(76); - }); - - it("still merges the tail above the watermark", () => { - const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); - const merged = applyHistory(trimmed, chunks(100, 110)); - expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110); - expect(merged.committed[0]?.seq).toBe(26); - }); + it("does not resurrect chunks below hiddenBeforeSeq on a full-cache merge", () => { + const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); + expect(trimmed.hiddenBeforeSeq).toBe(26); + // A later sync merges the FULL cache (seqs 1..101) — the unloaded prefix must stay out. + const merged = applyHistory(trimmed, chunks(1, 101)); + expect(merged.committed[0]?.seq).toBe(26); + expect(merged.committed).toHaveLength(76); + }); + + it("still merges the tail above the watermark", () => { + const trimmed = trimTranscript(stateWith(chunks(1, 101)), 100); + const merged = applyHistory(trimmed, chunks(100, 110)); + expect(merged.committed[merged.committed.length - 1]?.seq).toBe(110); + expect(merged.committed[0]?.seq).toBe(26); + }); }); describe("restoreEarlier", () => { - it("pages the newest `count` earlier chunks back in and lowers the watermark", () => { - const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000 - const restored = restoreEarlier(windowed, chunks(1, 1000), 64); - expect(restored.committed[0]?.seq).toBe(862); - expect(restored.committed).toHaveLength(75 + 64); - expect(restored.hiddenBeforeSeq).toBe(862); - expect(selectHasEarlier(restored)).toBe(true); - }); - - it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => { - const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25 - const restored = restoreEarlier(windowed, chunks(1, 100), 64); - expect(restored.committed).toHaveLength(100); - expect(restored.committed[0]?.seq).toBe(1); - expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert - expect(restored.hiddenThinkingCount).toBe(0); - expect(selectHasEarlier(restored)).toBe(false); - }); - - it("is the identity when nothing older is known locally (server may still hold more)", () => { - const windowed = windowTranscript(stateWith(chunks(50, 200)), 75); - const restored = restoreEarlier(windowed, [], 64); - expect(restored).toBe(windowed); - // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist. - expect(selectHasEarlier(restored)).toBe(true); - }); - - it("is the identity when the window already starts at seq 1", () => { - const state = stateWith(chunks(1, 10)); - expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state); - }); - - it("works on a server-windowed transcript (no local watermark)", () => { - // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000) - // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a - // backfilled run merges below it. - const state = stateWith(chunks(809, 1000)); - expect(state.hiddenBeforeSeq).toBe(0); - expect(selectHasEarlier(state)).toBe(true); - const restored = restoreEarlier(state, chunks(745, 808), 64); - expect(restored.committed[0]?.seq).toBe(745); - expect(restored.committed).toHaveLength(192 + 64); - expect(restored.hiddenBeforeSeq).toBe(745); - expect(selectHasEarlier(restored)).toBe(true); - }); - - it("decrements the hidden thinking count by the restored thinking chunks", () => { - const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)]; - const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking) - expect(trimmed.hiddenThinkingCount).toBe(2); - const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking) - expect(restored.hiddenBeforeSeq).toBe(2); - expect(restored.hiddenThinkingCount).toBe(1); - }); - - it("round-trips with trim: trim → restore-all yields the original committed list", () => { - const original = chunks(1, 101); - const trimmed = trimTranscript(stateWith(original), 100); - const restored = restoreEarlier(trimmed, original, 1000); - expect(restored.committed).toEqual(original); - expect(restored.hiddenBeforeSeq).toBe(1); - expect(selectHasEarlier(restored)).toBe(false); - }); + it("pages the newest `count` earlier chunks back in and lowers the watermark", () => { + const windowed = windowTranscript(stateWith(chunks(1, 1000)), 75); // loaded 926..1000 + const restored = restoreEarlier(windowed, chunks(1, 1000), 64); + expect(restored.committed[0]?.seq).toBe(862); + expect(restored.committed).toHaveLength(75 + 64); + expect(restored.hiddenBeforeSeq).toBe(862); + expect(selectHasEarlier(restored)).toBe(true); + }); + + it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => { + const windowed = windowTranscript(stateWith(chunks(1, 100)), 75); // hidden: 1..25 + const restored = restoreEarlier(windowed, chunks(1, 100), 64); + expect(restored.committed).toHaveLength(100); + expect(restored.committed[0]?.seq).toBe(1); + expect(restored.hiddenBeforeSeq).toBe(1); // floor at the origin — inert + expect(restored.hiddenThinkingCount).toBe(0); + expect(selectHasEarlier(restored)).toBe(false); + }); + + it("is the identity when nothing older is known locally (server may still hold more)", () => { + const windowed = windowTranscript(stateWith(chunks(50, 200)), 75); + const restored = restoreEarlier(windowed, [], 64); + expect(restored).toBe(windowed); + // seqs are 1-based gap-free: window starts at 126 ⇒ older chunks DO exist. + expect(selectHasEarlier(restored)).toBe(true); + }); + + it("is the identity when the window already starts at seq 1", () => { + const state = stateWith(chunks(1, 10)); + expect(restoreEarlier(state, chunks(1, 10), 5)).toBe(state); + }); + + it("works on a server-windowed transcript (no local watermark)", () => { + // A cold-cache fresh load with `?limit=` commits a suffix (seq 809..1000) + // with hiddenBeforeSeq still 0 — hasEarlier derives from seq > 1, and a + // backfilled run merges below it. + const state = stateWith(chunks(809, 1000)); + expect(state.hiddenBeforeSeq).toBe(0); + expect(selectHasEarlier(state)).toBe(true); + const restored = restoreEarlier(state, chunks(745, 808), 64); + expect(restored.committed[0]?.seq).toBe(745); + expect(restored.committed).toHaveLength(192 + 64); + expect(restored.hiddenBeforeSeq).toBe(745); + expect(selectHasEarlier(restored)).toBe(true); + }); + + it("decrements the hidden thinking count by the restored thinking chunks", () => { + const committed = [chunk(1, "thinking"), chunk(2), chunk(3, "thinking"), ...chunks(4, 12)]; + const trimmed = trimTranscript(stateWith(committed), 10); // drops 3: seqs 1..3 (2 thinking) + expect(trimmed.hiddenThinkingCount).toBe(2); + const restored = restoreEarlier(trimmed, committed, 2); // restores seqs 2..3 (1 thinking) + expect(restored.hiddenBeforeSeq).toBe(2); + expect(restored.hiddenThinkingCount).toBe(1); + }); + + it("round-trips with trim: trim → restore-all yields the original committed list", () => { + const original = chunks(1, 101); + const trimmed = trimTranscript(stateWith(original), 100); + const restored = restoreEarlier(trimmed, original, 1000); + expect(restored.committed).toEqual(original); + expect(restored.hiddenBeforeSeq).toBe(1); + expect(selectHasEarlier(restored)).toBe(false); + }); }); diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts index 7791721..9846357 100644 --- a/src/core/chunks/trim.ts +++ b/src/core/chunks/trim.ts @@ -29,54 +29,54 @@ export const MAX_CHAT_LIMIT = 100_000; * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT]. */ export function normalizeChatLimit(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_LIMIT; - const n = Math.floor(value); - if (n < MIN_CHAT_LIMIT) return MIN_CHAT_LIMIT; - if (n > MAX_CHAT_LIMIT) return MAX_CHAT_LIMIT; - return n; + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_CHAT_LIMIT; + const n = Math.floor(value); + if (n < MIN_CHAT_LIMIT) return MIN_CHAT_LIMIT; + if (n > MAX_CHAT_LIMIT) return MAX_CHAT_LIMIT; + return n; } /** The bulk-unload unit: a quarter of the limit, rounded up. */ export function unloadCount(limit: number): number { - return Math.ceil(limit / 4); + return Math.ceil(limit / 4); } /** The fresh-load window: 75% of the limit, rounded down (≥ 1). */ export function initialWindowSize(limit: number): number { - return Math.max(1, Math.floor(limit * 0.75)); + return Math.max(1, Math.floor(limit * 0.75)); } /** Total loaded (rendered) chunk count: committed + provisional + accumulating. */ function totalCount(state: TranscriptState): number { - return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0); + return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0); } function countThinking(chunks: readonly StoredChunk[]): number { - let n = 0; - for (const c of chunks) { - if (c.chunk.type === "thinking") n++; - } - return n; + let n = 0; + for (const c of chunks) { + if (c.chunk.type === "thinking") n++; + } + return n; } /** Drop the `drop` oldest committed chunks, advancing the watermark + thinking base. */ function dropOldest(state: TranscriptState, drop: number): TranscriptState { - const dropped = state.committed.slice(0, drop); - const kept = state.committed.slice(drop); - const first = kept[0]; - const lastDropped = dropped[dropped.length - 1]; - let hiddenBeforeSeq = state.hiddenBeforeSeq; - if (first !== undefined) { - hiddenBeforeSeq = first.seq; - } else if (lastDropped !== undefined) { - hiddenBeforeSeq = lastDropped.seq + 1; - } - return { - ...state, - committed: kept, - hiddenBeforeSeq, - hiddenThinkingCount: state.hiddenThinkingCount + countThinking(dropped), - }; + const dropped = state.committed.slice(0, drop); + const kept = state.committed.slice(drop); + const first = kept[0]; + const lastDropped = dropped[dropped.length - 1]; + let hiddenBeforeSeq = state.hiddenBeforeSeq; + if (first !== undefined) { + hiddenBeforeSeq = first.seq; + } else if (lastDropped !== undefined) { + hiddenBeforeSeq = lastDropped.seq + 1; + } + return { + ...state, + committed: kept, + hiddenBeforeSeq, + hiddenThinkingCount: state.hiddenThinkingCount + countThinking(dropped), + }; } /** @@ -89,36 +89,36 @@ function dropOldest(state: TranscriptState, drop: number): TranscriptState { * to keep the browser responsive during very long turns. */ export function trimTranscript(state: TranscriptState, limit: number): TranscriptState { - if (!Number.isFinite(limit) || limit <= 0) return state; - const total = totalCount(state); - if (total <= limit) return state; - const quarter = unloadCount(limit); - const passes = Math.ceil((total - limit) / quarter); - - // First, drop oldest committed chunks (the usual path). - const committedDrop = Math.min(passes * quarter, state.committed.length); - let next = committedDrop > 0 ? dropOldest(state, committedDrop) : state; - - // If still over the limit and committed is exhausted, drop oldest - // provisional chunks (the in-flight turn). These chunks have no seq - // (not yet persisted) and can't be "Show earlier" — but dropping them - // keeps the browser responsive. They'll come back as committed when - // the turn seals and syncTail fetches them from the server. - const remaining = totalCount(next); - if (remaining > limit && next.provisional.length > 0) { - const provisionalDrop = Math.min( - Math.ceil((remaining - limit) / quarter) * quarter, - next.provisional.length, - ); - if (provisionalDrop > 0) { - next = { - ...next, - provisional: next.provisional.slice(provisionalDrop), - }; - } - } - - return next; + if (!Number.isFinite(limit) || limit <= 0) return state; + const total = totalCount(state); + if (total <= limit) return state; + const quarter = unloadCount(limit); + const passes = Math.ceil((total - limit) / quarter); + + // First, drop oldest committed chunks (the usual path). + const committedDrop = Math.min(passes * quarter, state.committed.length); + let next = committedDrop > 0 ? dropOldest(state, committedDrop) : state; + + // If still over the limit and committed is exhausted, drop oldest + // provisional chunks (the in-flight turn). These chunks have no seq + // (not yet persisted) and can't be "Show earlier" — but dropping them + // keeps the browser responsive. They'll come back as committed when + // the turn seals and syncTail fetches them from the server. + const remaining = totalCount(next); + if (remaining > limit && next.provisional.length > 0) { + const provisionalDrop = Math.min( + Math.ceil((remaining - limit) / quarter) * quarter, + next.provisional.length, + ); + if (provisionalDrop > 0) { + next = { + ...next, + provisional: next.provisional.slice(provisionalDrop), + }; + } + } + + return next; } /** @@ -127,10 +127,10 @@ export function trimTranscript(state: TranscriptState, limit: number): Transcrip * already within the window. */ export function windowTranscript(state: TranscriptState, maxCommitted: number): TranscriptState { - if (!Number.isFinite(maxCommitted) || maxCommitted < 0) return state; - const drop = state.committed.length - maxCommitted; - if (drop <= 0) return state; - return dropOldest(state, drop); + if (!Number.isFinite(maxCommitted) || maxCommitted < 0) return state; + const drop = state.committed.length - maxCommitted; + if (drop <= 0) return state; + return dropOldest(state, drop); } /** @@ -139,7 +139,7 @@ export function windowTranscript(state: TranscriptState, maxCommitted: number): * committed list (all-provisional overflow). 0 = window start unknown/origin. */ function oldestLoadedSeq(state: TranscriptState): number { - return state.committed[0]?.seq ?? state.hiddenBeforeSeq; + return state.committed[0]?.seq ?? state.hiddenBeforeSeq; } /** @@ -154,22 +154,22 @@ function oldestLoadedSeq(state: TranscriptState): number { * contractual origin) or nothing older is known locally. */ export function restoreEarlier( - state: TranscriptState, - earlier: readonly StoredChunk[], - count: number, + state: TranscriptState, + earlier: readonly StoredChunk[], + count: number, ): TranscriptState { - const oldest = oldestLoadedSeq(state); - if (oldest <= 1) return state; - const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq); - if (below.length === 0) return state; - const keep = below.slice(-Math.max(1, count)); - const firstKept = keep[0]; - return { - ...state, - committed: [...keep, ...state.committed], - hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq, - hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)), - }; + const oldest = oldestLoadedSeq(state); + if (oldest <= 1) return state; + const below = earlier.filter((c) => c.seq < oldest).sort((a, b) => a.seq - b.seq); + if (below.length === 0) return state; + const keep = below.slice(-Math.max(1, count)); + const firstKept = keep[0]; + return { + ...state, + committed: [...keep, ...state.committed], + hiddenBeforeSeq: firstKept?.seq ?? state.hiddenBeforeSeq, + hiddenThinkingCount: Math.max(0, state.hiddenThinkingCount - countThinking(keep)), + }; } /** @@ -181,5 +181,5 @@ export function restoreEarlier( * fresh load. */ export function selectHasEarlier(state: TranscriptState): boolean { - return oldestLoadedSeq(state) > 1; + return oldestLoadedSeq(state) > 1; } diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts index 32b9984..2ced736 100644 --- a/src/core/chunks/types.ts +++ b/src/core/chunks/types.ts @@ -2,76 +2,76 @@ import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@d /** A chunk being accumulated from streaming deltas (text or thinking). */ export interface AccumulatingChunk { - readonly kind: "text" | "thinking"; - readonly text: string; + readonly kind: "text" | "thinking"; + readonly text: string; } /** A provisional chunk that has no authoritative seq yet. */ export interface ProvisionalChunk { - readonly role: Role; - readonly chunk: Chunk; + readonly role: Role; + readonly chunk: Chunk; } /** The transcript reducer state. Holds committed history + live in-flight turn. */ export interface TranscriptState { - readonly committed: readonly StoredChunk[]; - readonly provisional: readonly ProvisionalChunk[]; - readonly accumulating: AccumulatingChunk | null; - readonly currentTurnId: string | null; - readonly latestUsage: Usage | null; - readonly sealedTurnId: string | null; - /** - * The chat-limit UNLOAD watermark: committed chunks with `seq <` this are - * unloaded (not in `committed`, not rendered) to keep long transcripts cheap. - * `0` = nothing unloaded. `applyHistory` refuses chunks below it (a cache/tail - * merge must not resurrect what the trim dropped); "Show earlier messages" - * lowers it via `restoreEarlier`. See `trim.ts`. - */ - readonly hiddenBeforeSeq: number; - /** - * How many thinking-type chunks are currently unloaded below the watermark. - * Pure render-key bookkeeping: the UI keys thinking collapses by ORDINAL (so - * the key survives the provisional→committed seal transition), and this base - * keeps those ordinals stable when a trim removes older thinking chunks — - * otherwise every remaining collapse would shift keys and swap/lose its - * open state mid-stream. - */ - readonly hiddenThinkingCount: number; - /** - * True while a turn is generating on the server — derived STRUCTURALLY from the - * event stream: a `turn-start` (or any turn delta) with no matching `done` / - * `turn-sealed` / `error` yet. A late-joiner that subscribes mid-turn gets the - * in-flight turn replayed from its `turn-start`, so this lights up for any - * watching client. NOT inferred from the free-form `status` event string. - */ - readonly generating: boolean; - /** - * The latest `provider-retry` event for the current turn, or `null` when no - * retry is pending. TRANSIENT UI state (never a Chunk — never committed or - * provisional, so it can NEVER pollute the model's prompt or be replayed on a - * reload/replay of past turns: only committed seq'd chunks are history). Set - * by `foldEvent` on each `provider-retry` (the latest coalesces over previous - * so a single updating "retrying…" banner shows the newest attempt + delay); - * cleared when the model's content resumes (`text-delta`/`reasoning-delta`/ - * `tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), - * or a new turn starts (`turn-start`). Also cleared on a WS reconnect - * (`clearGenerating`) — a retry pending at disconnect is stale once we - * re-subscribe (provider-retry events are not replayed). - */ - readonly providerRetry: TurnProviderRetryEvent | null; + readonly committed: readonly StoredChunk[]; + readonly provisional: readonly ProvisionalChunk[]; + readonly accumulating: AccumulatingChunk | null; + readonly currentTurnId: string | null; + readonly latestUsage: Usage | null; + readonly sealedTurnId: string | null; + /** + * The chat-limit UNLOAD watermark: committed chunks with `seq <` this are + * unloaded (not in `committed`, not rendered) to keep long transcripts cheap. + * `0` = nothing unloaded. `applyHistory` refuses chunks below it (a cache/tail + * merge must not resurrect what the trim dropped); "Show earlier messages" + * lowers it via `restoreEarlier`. See `trim.ts`. + */ + readonly hiddenBeforeSeq: number; + /** + * How many thinking-type chunks are currently unloaded below the watermark. + * Pure render-key bookkeeping: the UI keys thinking collapses by ORDINAL (so + * the key survives the provisional→committed seal transition), and this base + * keeps those ordinals stable when a trim removes older thinking chunks — + * otherwise every remaining collapse would shift keys and swap/lose its + * open state mid-stream. + */ + readonly hiddenThinkingCount: number; + /** + * True while a turn is generating on the server — derived STRUCTURALLY from the + * event stream: a `turn-start` (or any turn delta) with no matching `done` / + * `turn-sealed` / `error` yet. A late-joiner that subscribes mid-turn gets the + * in-flight turn replayed from its `turn-start`, so this lights up for any + * watching client. NOT inferred from the free-form `status` event string. + */ + readonly generating: boolean; + /** + * The latest `provider-retry` event for the current turn, or `null` when no + * retry is pending. TRANSIENT UI state (never a Chunk — never committed or + * provisional, so it can NEVER pollute the model's prompt or be replayed on a + * reload/replay of past turns: only committed seq'd chunks are history). Set + * by `foldEvent` on each `provider-retry` (the latest coalesces over previous + * so a single updating "retrying…" banner shows the newest attempt + delay); + * cleared when the model's content resumes (`text-delta`/`reasoning-delta`/ + * `tool-call`/`tool-result`), the turn ends (`done`/`turn-sealed`/`error`), + * or a new turn starts (`turn-start`). Also cleared on a WS reconnect + * (`clearGenerating`) — a retry pending at disconnect is stale once we + * re-subscribe (provider-retry events are not replayed). + */ + readonly providerRetry: TurnProviderRetryEvent | null; } /** A chunk ready for rendering: either committed (with seq) or provisional. */ export interface RenderedChunk { - readonly seq: number | null; - readonly role: Role; - readonly chunk: Chunk; - readonly provisional: boolean; - /** - * True only for the single chunk currently being accumulated from live deltas - * (the in-flight text/thinking the model is actively generating). Absent/false - * once flushed or committed. Lets the UI show a live indicator (e.g. loading - * dots on streaming thinking) and drop it the moment generation moves on. - */ - readonly streaming?: boolean; + readonly seq: number | null; + readonly role: Role; + readonly chunk: Chunk; + readonly provisional: boolean; + /** + * True only for the single chunk currently being accumulated from live deltas + * (the in-flight text/thinking the model is actively generating). Absent/false + * once flushed or committed. Lets the UI show a live indicator (e.g. loading + * dots on streaming thinking) and drop it the moment generation moves on. + */ + readonly streaming?: boolean; } diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts index 6a4bd38..c7c4fbb 100644 --- a/src/core/metrics/format.test.ts +++ b/src/core/metrics/format.test.ts @@ -1,369 +1,369 @@ import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - computeCachePct, - computeContextUsage, - computeExpectedCachePct, - computeTps, - formatCompactTokens, - formatContextSize, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, + computeCachePct, + computeContextUsage, + computeExpectedCachePct, + computeTps, + formatCompactTokens, + formatContextSize, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, } from "./format"; describe("computeTps", () => { - it("null when elapsed missing", () => { - expect(computeTps(100, undefined)).toBeNull(); - }); + it("null when elapsed missing", () => { + expect(computeTps(100, undefined)).toBeNull(); + }); - it("null when elapsed is zero", () => { - expect(computeTps(100, 0)).toBeNull(); - }); + it("null when elapsed is zero", () => { + expect(computeTps(100, 0)).toBeNull(); + }); - it("null when elapsed is negative", () => { - expect(computeTps(100, -100)).toBeNull(); - }); + it("null when elapsed is negative", () => { + expect(computeTps(100, -100)).toBeNull(); + }); - it("computes tokens per second", () => { - expect(computeTps(1000, 2000)).toBe(500); - }); + it("computes tokens per second", () => { + expect(computeTps(1000, 2000)).toBe(500); + }); - it("computes fractional tps", () => { - expect(computeTps(100, 3000)).toBeCloseTo(33.33, 1); - }); + it("computes fractional tps", () => { + expect(computeTps(100, 3000)).toBeCloseTo(33.33, 1); + }); }); describe("viewStepMetrics", () => { - it("formats tokens with thousands separator, tps, and durations", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 1234, outputTokens: 567 }, - ttftMs: 820, - decodeMs: 1200, - genTotalMs: 2020, - }; - const view = viewStepMetrics(step, 0); - expect(view.label).toBe("step 1"); - expect(view.tokensLabel).toBe("1,801 tok"); - expect(view.tps).toBe("473 tok/s"); - expect(view.ttft).toBe("820ms"); - expect(view.decode).toBe("1.2s"); - expect(view.genTotal).toBe("2.0s"); - }); - - it("handles missing timing fields", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }; - const view = viewStepMetrics(step, 0); - expect(view.tps).toBeNull(); - expect(view.ttft).toBeNull(); - expect(view.decode).toBeNull(); - expect(view.genTotal).toBeNull(); - }); - - it("formats duration < 1s as ms", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - ttftMs: 42, - }; - const view = viewStepMetrics(step, 0); - expect(view.ttft).toBe("42ms"); - }); - - it("formats duration >= 1s as seconds", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - genTotalMs: 3200, - }; - const view = viewStepMetrics(step, 0); - expect(view.genTotal).toBe("3.2s"); - }); - - it("uses step index for label", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 10, outputTokens: 5 }, - }; - expect(viewStepMetrics(step, 2).label).toBe("step 3"); - }); - - it("tps uses decodeMs (not genTotalMs)", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - decodeMs: 500, - genTotalMs: 800, - }; - const view = viewStepMetrics(step, 0); - // 50 / (500/1000) = 100 tok/s, NOT 50/(800/1000)=62.5 - expect(view.tps).toBe("100 tok/s"); - }); - - it("tps falls back to genTotalMs when decodeMs absent", () => { - const step: StepMetrics = { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 800, - }; - const view = viewStepMetrics(step, 0); - // 50 / (800/1000) = 62.5 → rounds to 63 - expect(view.tps).toBe("63 tok/s"); - }); + it("formats tokens with thousands separator, tps, and durations", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 1234, outputTokens: 567 }, + ttftMs: 820, + decodeMs: 1200, + genTotalMs: 2020, + }; + const view = viewStepMetrics(step, 0); + expect(view.label).toBe("step 1"); + expect(view.tokensLabel).toBe("1,801 tok"); + expect(view.tps).toBe("473 tok/s"); + expect(view.ttft).toBe("820ms"); + expect(view.decode).toBe("1.2s"); + expect(view.genTotal).toBe("2.0s"); + }); + + it("handles missing timing fields", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }; + const view = viewStepMetrics(step, 0); + expect(view.tps).toBeNull(); + expect(view.ttft).toBeNull(); + expect(view.decode).toBeNull(); + expect(view.genTotal).toBeNull(); + }); + + it("formats duration < 1s as ms", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + ttftMs: 42, + }; + const view = viewStepMetrics(step, 0); + expect(view.ttft).toBe("42ms"); + }); + + it("formats duration >= 1s as seconds", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + genTotalMs: 3200, + }; + const view = viewStepMetrics(step, 0); + expect(view.genTotal).toBe("3.2s"); + }); + + it("uses step index for label", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 10, outputTokens: 5 }, + }; + expect(viewStepMetrics(step, 2).label).toBe("step 3"); + }); + + it("tps uses decodeMs (not genTotalMs)", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + decodeMs: 500, + genTotalMs: 800, + }; + const view = viewStepMetrics(step, 0); + // 50 / (500/1000) = 100 tok/s, NOT 50/(800/1000)=62.5 + expect(view.tps).toBe("100 tok/s"); + }); + + it("tps falls back to genTotalMs when decodeMs absent", () => { + const step: StepMetrics = { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 800, + }; + const view = viewStepMetrics(step, 0); + // 50 / (800/1000) = 62.5 → rounds to 63 + expect(view.tps).toBe("63 tok/s"); + }); }); describe("viewTurnMetrics", () => { - it("formats total tokens and breakdown", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 1000, outputTokens: 234 }, - durationMs: 5000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 1000, outputTokens: 234 }, - decodeMs: 3000, - genTotalMs: 4000, - }, - ], - }; - const view = viewTurnMetrics(turn); - expect(view.tokensLabel).toBe("1,234 tok"); - expect(view.breakdown).toBe("1,000 in / 234 out"); - expect(view.tps).toBe("78 tok/s"); - expect(view.duration).toBe("5.0s"); - }); - - it("breakdown includes cache only when present", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 1000, outputTokens: 234, cacheReadTokens: 500 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); - }); - - it("breakdown omits cache when not present", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.breakdown).toBe("100 in / 50 out"); - }); - - it("tps is null when no step has decodeMs or genTotalMs", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - }, - ], - }; - const view = viewTurnMetrics(turn); - expect(view.tps).toBeNull(); - }); - - it("duration is null when durationMs absent", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [], - }; - const view = viewTurnMetrics(turn); - expect(view.duration).toBeNull(); - }); - - it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { - const turn: TurnMetrics = { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 150 }, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - decodeMs: 800, - genTotalMs: 1000, - }, - { - stepId: "s2" as StepId, - usage: { inputTokens: 200, outputTokens: 100 }, - genTotalMs: 2000, - }, - ], - }; - const view = viewTurnMetrics(turn); - // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms - // 150 / (2800/1000) = 53.57 → rounds to 54 - expect(view.tps).toBe("54 tok/s"); - }); + it("formats total tokens and breakdown", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 234 }, + durationMs: 5000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 1000, outputTokens: 234 }, + decodeMs: 3000, + genTotalMs: 4000, + }, + ], + }; + const view = viewTurnMetrics(turn); + expect(view.tokensLabel).toBe("1,234 tok"); + expect(view.breakdown).toBe("1,000 in / 234 out"); + expect(view.tps).toBe("78 tok/s"); + expect(view.duration).toBe("5.0s"); + }); + + it("breakdown includes cache only when present", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 1000, outputTokens: 234, cacheReadTokens: 500 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache"); + }); + + it("breakdown omits cache when not present", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.breakdown).toBe("100 in / 50 out"); + }); + + it("tps is null when no step has decodeMs or genTotalMs", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + }, + ], + }; + const view = viewTurnMetrics(turn); + expect(view.tps).toBeNull(); + }); + + it("duration is null when durationMs absent", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [], + }; + const view = viewTurnMetrics(turn); + expect(view.duration).toBeNull(); + }); + + it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => { + const turn: TurnMetrics = { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 150 }, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + decodeMs: 800, + genTotalMs: 1000, + }, + { + stepId: "s2" as StepId, + usage: { inputTokens: 200, outputTokens: 100 }, + genTotalMs: 2000, + }, + ], + }; + const view = viewTurnMetrics(turn); + // step1 uses decodeMs=800, step2 falls back to genTotalMs=2000 → total=2800ms + // 150 / (2800/1000) = 53.57 → rounds to 54 + expect(view.tps).toBe("54 tok/s"); + }); }); describe("computeCachePct", () => { - it("is cacheReadTokens / inputTokens as a rounded percentage", () => { - expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( - 94, - ); - expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); - }); - - it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { - expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); - }); - - it("is 0 when there are no input tokens (guard divide-by-zero)", () => { - expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); - }); - - it("clamps to 100 if read somehow exceeds input", () => { - expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); - }); + it("is cacheReadTokens / inputTokens as a rounded percentage", () => { + expect(computeCachePct({ inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 })).toBe( + 94, + ); + expect(computeCachePct({ inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 })).toBe(14); + }); + + it("is 0 when cacheReadTokens absent (legitimate miss, not missing data)", () => { + expect(computeCachePct({ inputTokens: 1000, outputTokens: 50 })).toBe(0); + }); + + it("is 0 when there are no input tokens (guard divide-by-zero)", () => { + expect(computeCachePct({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 5 })).toBe(0); + }); + + it("clamps to 100 if read somehow exceeds input", () => { + expect(computeCachePct({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 })).toBe(100); + }); }); describe("viewCacheRate", () => { - it("success level for a high hit rate (>= 66)", () => { - const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); - expect(v.pct).toBe(93); - expect(v.level).toBe("success"); - expect(v.isHit).toBe(true); - }); - - it("warning level for a mid hit rate (33..65)", () => { - const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); - expect(v.pct).toBe(54); - expect(v.level).toBe("warning"); - }); - - it("error level for a low hit rate (< 33), including a legitimate 0%", () => { - expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( - "error", - ); - const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); - expect(miss.pct).toBe(0); - expect(miss.level).toBe("error"); - expect(miss.isHit).toBe(false); - }); + it("success level for a high hit rate (>= 66)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 93 }); + expect(v.pct).toBe(93); + expect(v.level).toBe("success"); + expect(v.isHit).toBe(true); + }); + + it("warning level for a mid hit rate (33..65)", () => { + const v = viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 54 }); + expect(v.pct).toBe(54); + expect(v.level).toBe("warning"); + }); + + it("error level for a low hit rate (< 33), including a legitimate 0%", () => { + expect(viewCacheRate({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 14 }).level).toBe( + "error", + ); + const miss = viewCacheRate({ inputTokens: 1000, outputTokens: 50 }); + expect(miss.pct).toBe(0); + expect(miss.level).toBe("error"); + expect(miss.isHit).toBe(false); + }); }); describe("computeExpectedCachePct", () => { - it("null when there is no prior turn (first turn has no baseline)", () => { - expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); - }); - - it("null when the prior turn cached nothing (denominator 0)", () => { - const prev = { inputTokens: 100, outputTokens: 0 }; - const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; - expect(computeExpectedCachePct(current, prev)).toBeNull(); - }); - - it("100% when the whole prior cached prefix was read back (backend worked example)", () => { - // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. - const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; - const current = { - inputTokens: 8462, - outputTokens: 0, - cacheReadTokens: 5146, - cacheWriteTokens: 3313, - }; - expect(computeExpectedCachePct(current, prev)).toBe(100); - }); - - it("drops below 100% when the cache busted (read < prior prefix)", () => { - const prev = { - inputTokens: 1000, - outputTokens: 0, - cacheReadTokens: 100, - cacheWriteTokens: 900, - }; - const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; - // 500 / (100 + 900) = 50% - expect(computeExpectedCachePct(current, prev)).toBe(50); - }); - - it("clamps to 100 if read somehow exceeds the prior prefix", () => { - const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; - const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; - expect(computeExpectedCachePct(current, prev)).toBe(100); - }); + it("null when there is no prior turn (first turn has no baseline)", () => { + expect(computeExpectedCachePct({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("null when the prior turn cached nothing (denominator 0)", () => { + const prev = { inputTokens: 100, outputTokens: 0 }; + const current = { inputTokens: 200, outputTokens: 0, cacheReadTokens: 50 }; + expect(computeExpectedCachePct(current, prev)).toBeNull(); + }); + + it("100% when the whole prior cached prefix was read back (backend worked example)", () => { + // turn 1: cacheRead 0, cacheWrite 5146 → prefix 5146; turn 2 reads 5146 back. + const prev = { inputTokens: 5149, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 5146 }; + const current = { + inputTokens: 8462, + outputTokens: 0, + cacheReadTokens: 5146, + cacheWriteTokens: 3313, + }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); + + it("drops below 100% when the cache busted (read < prior prefix)", () => { + const prev = { + inputTokens: 1000, + outputTokens: 0, + cacheReadTokens: 100, + cacheWriteTokens: 900, + }; + const current = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 500 }; + // 500 / (100 + 900) = 50% + expect(computeExpectedCachePct(current, prev)).toBe(50); + }); + + it("clamps to 100 if read somehow exceeds the prior prefix", () => { + const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 }; + const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 }; + expect(computeExpectedCachePct(current, prev)).toBe(100); + }); }); describe("viewExpectedCache", () => { - it("null view when it cannot be derived (no prior turn)", () => { - expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); - }); - - it("success level + hit flag for full retention", () => { - const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; - const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; - const v = viewExpectedCache(current, prev); - expect(v?.pct).toBe(100); - expect(v?.level).toBe("success"); - expect(v?.isHit).toBe(true); - }); + it("null view when it cannot be derived (no prior turn)", () => { + expect(viewExpectedCache({ inputTokens: 100, outputTokens: 0 }, null)).toBeNull(); + }); + + it("success level + hit flag for full retention", () => { + const prev = { inputTokens: 5149, outputTokens: 0, cacheWriteTokens: 5146 }; + const current = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 }; + const v = viewExpectedCache(current, prev); + expect(v?.pct).toBe(100); + expect(v?.level).toBe("success"); + expect(v?.isHit).toBe(true); + }); }); describe("formatContextSize", () => { - it("formats a defined count with thousands separators", () => { - expect(formatContextSize(34102)).toBe("34,102 tokens in context"); - }); + it("formats a defined count with thousands separators", () => { + expect(formatContextSize(34102)).toBe("34,102 tokens in context"); + }); - it("renders a placeholder for undefined (never 0)", () => { - expect(formatContextSize(undefined)).toBe("context size unknown"); - }); + it("renders a placeholder for undefined (never 0)", () => { + expect(formatContextSize(undefined)).toBe("context size unknown"); + }); - it("renders an explicit 0 as zero tokens (a real reported value)", () => { - expect(formatContextSize(0)).toBe("0 tokens in context"); - }); + it("renders an explicit 0 as zero tokens (a real reported value)", () => { + expect(formatContextSize(0)).toBe("0 tokens in context"); + }); }); describe("formatCompactTokens", () => { - it("renders sub-1k counts as-is", () => { - expect(formatCompactTokens(0)).toBe("0"); - expect(formatCompactTokens(812)).toBe("812"); - }); - - it("renders thousands with one decimal (rounded ≥100k)", () => { - expect(formatCompactTokens(12300)).toBe("12.3k"); - expect(formatCompactTokens(150000)).toBe("150k"); - }); - - it("renders millions with one decimal", () => { - expect(formatCompactTokens(1_200_000)).toBe("1.2M"); - expect(formatCompactTokens(1_000_000)).toBe("1.0M"); - }); + it("renders sub-1k counts as-is", () => { + expect(formatCompactTokens(0)).toBe("0"); + expect(formatCompactTokens(812)).toBe("812"); + }); + + it("renders thousands with one decimal (rounded ≥100k)", () => { + expect(formatCompactTokens(12300)).toBe("12.3k"); + expect(formatCompactTokens(150000)).toBe("150k"); + }); + + it("renders millions with one decimal", () => { + expect(formatCompactTokens(1_200_000)).toBe("1.2M"); + expect(formatCompactTokens(1_000_000)).toBe("1.0M"); + }); }); describe("computeContextUsage", () => { - it("computes an unrounded clamped percent against the limit", () => { - const u = computeContextUsage(34102, 1_000_000); - expect(u.current).toBe(34102); - expect(u.max).toBe(1_000_000); - expect(u.percent).toBeCloseTo(3.4102, 4); - }); - - it("treats unknown contextSize as current 0", () => { - const u = computeContextUsage(undefined, 1_000_000); - expect(u.current).toBe(0); - expect(u.percent).toBe(0); - }); - - it("clamps percent to [0,100] and over-limit reads 100", () => { - expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); - }); - - it("max null (no/zero limit) ⇒ percent null", () => { - expect(computeContextUsage(5000, null).percent).toBeNull(); - expect(computeContextUsage(5000, 0).percent).toBeNull(); - expect(computeContextUsage(5000, null).max).toBeNull(); - }); + it("computes an unrounded clamped percent against the limit", () => { + const u = computeContextUsage(34102, 1_000_000); + expect(u.current).toBe(34102); + expect(u.max).toBe(1_000_000); + expect(u.percent).toBeCloseTo(3.4102, 4); + }); + + it("treats unknown contextSize as current 0", () => { + const u = computeContextUsage(undefined, 1_000_000); + expect(u.current).toBe(0); + expect(u.percent).toBe(0); + }); + + it("clamps percent to [0,100] and over-limit reads 100", () => { + expect(computeContextUsage(2_000_000, 1_000_000).percent).toBe(100); + }); + + it("max null (no/zero limit) ⇒ percent null", () => { + expect(computeContextUsage(5000, null).percent).toBeNull(); + expect(computeContextUsage(5000, 0).percent).toBeNull(); + expect(computeContextUsage(5000, null).max).toBeNull(); + }); }); diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts index 534277c..56e74e4 100644 --- a/src/core/metrics/format.ts +++ b/src/core/metrics/format.ts @@ -2,19 +2,19 @@ import type { StepMetrics, TurnMetrics, Usage } from "@dispatch/wire"; import type { CacheRateView, StepMetricsView, TurnMetricsView } from "./types"; function formatTokens(n: number): string { - return n.toLocaleString("en-US"); + return n.toLocaleString("en-US"); } function formatDuration(ms: number | undefined): string | null { - if (ms === undefined || ms <= 0) return null; - if (ms < 1000) return `${Math.round(ms)}ms`; - return `${(ms / 1000).toFixed(1)}s`; + if (ms === undefined || ms <= 0) return null; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; } function formatTps(tps: number | null): string | null { - if (tps === null) return null; - if (tps < 10) return `${tps.toFixed(1)} tok/s`; - return `${Math.round(tps)} tok/s`; + if (tps === null) return null; + if (tps < 10) return `${tps.toFixed(1)} tok/s`; + return `${Math.round(tps)} tok/s`; } /** @@ -24,8 +24,8 @@ function formatTps(tps: number | null): string | null { * Never renders `0` for the unknown case. */ export function formatContextSize(n: number | undefined): string { - if (n === undefined) return "context size unknown"; - return `${formatTokens(n)} tokens in context`; + if (n === undefined) return "context size unknown"; + return `${formatTokens(n)} tokens in context`; } /** @@ -33,13 +33,13 @@ export function formatContextSize(n: number | undefined): string { * thousands-separated numbers live elsewhere; this trades precision for width. */ export function formatCompactTokens(n: number): string { - if (n < 1000) return `${n}`; - if (n < 1_000_000) { - const k = n / 1000; - return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; - } - const m = n / 1_000_000; - return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; + if (n < 1000) return `${n}`; + if (n < 1_000_000) { + const k = n / 1000; + return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; + } + const m = n / 1_000_000; + return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; } /** @@ -52,51 +52,51 @@ export function formatCompactTokens(n: number): string { * reads non-zero. `percent` is `null` when `max` is unknown (no bar/denominator). */ export interface ContextUsage { - readonly current: number; - readonly max: number | null; - readonly percent: number | null; + readonly current: number; + readonly max: number | null; + readonly percent: number | null; } export function computeContextUsage( - contextSize: number | undefined, - contextLimit: number | null | undefined, + contextSize: number | undefined, + contextLimit: number | null | undefined, ): ContextUsage { - const current = contextSize ?? 0; - const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; - const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); - return { current, max, percent }; + const current = contextSize ?? 0; + const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null; + const percent = max === null ? null : Math.max(0, Math.min(100, (current / max) * 100)); + return { current, max, percent }; } /** Compute tokens-per-second. Returns null when elapsed time is absent or zero. */ export function computeTps(outputTokens: number, elapsedMs: number | undefined): number | null { - if (elapsedMs === undefined || elapsedMs <= 0) return null; - return outputTokens / (elapsedMs / 1000); + if (elapsedMs === undefined || elapsedMs <= 0) return null; + return outputTokens / (elapsedMs / 1000); } function totalTokens(u: Usage): number { - return u.inputTokens + u.outputTokens; + return u.inputTokens + u.outputTokens; } function formatBreakdown(u: Usage): string { - let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; - if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { - s += ` / ${formatTokens(u.cacheReadTokens)} cache`; - } - return s; + let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`; + if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) { + s += ` / ${formatTokens(u.cacheReadTokens)} cache`; + } + return s; } /** Build a formatted view of a single step's metrics. */ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsView { - const total = totalTokens(step.usage); - const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); - return { - label: `step ${index + 1}`, - tokensLabel: `${formatTokens(total)} tok`, - tps: formatTps(tps), - ttft: formatDuration(step.ttftMs), - decode: formatDuration(step.decodeMs), - genTotal: formatDuration(step.genTotalMs), - }; + const total = totalTokens(step.usage); + const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs); + return { + label: `step ${index + 1}`, + tokensLabel: `${formatTokens(total)} tok`, + tps: formatTps(tps), + ttft: formatDuration(step.ttftMs), + decode: formatDuration(step.decodeMs), + genTotal: formatDuration(step.genTotalMs), + }; } /** @@ -105,24 +105,24 @@ export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsVi * missing data). Returns 0 when there are no input tokens. */ export function computeCachePct(u: Usage): number { - const read = u.cacheReadTokens ?? 0; - if (u.inputTokens <= 0) return 0; - const rate = read / u.inputTokens; - const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; - return Math.round(clamped * 100); + const read = u.cacheReadTokens ?? 0; + if (u.inputTokens <= 0) return 0; + const rate = read / u.inputTokens; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); } /** Colour severity for a cache hit percentage (badge colour). */ function cacheLevel(pct: number): "success" | "warning" | "error" { - if (pct >= 66) return "success"; - if (pct >= 33) return "warning"; - return "error"; + if (pct >= 66) return "success"; + if (pct >= 33) return "warning"; + return "error"; } /** Build a view of a cache hit rate (percentage + colour level + hit flag). */ export function viewCacheRate(u: Usage): CacheRateView { - const pct = computeCachePct(u); - return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; + const pct = computeCachePct(u); + return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 }; } /** @@ -135,13 +135,13 @@ export function viewCacheRate(u: Usage): CacheRateView { * prior turn cached nothing (denominator <= 0) — distinct from a real 0%. */ export function computeExpectedCachePct(current: Usage, prev: Usage | null): number | null { - if (prev === null) return null; - const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); - if (denom <= 0) return null; - const read = current.cacheReadTokens ?? 0; - const rate = read / denom; - const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; - return Math.round(clamped * 100); + if (prev === null) return null; + const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0); + if (denom <= 0) return null; + const read = current.cacheReadTokens ?? 0; + const rate = read / denom; + const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate; + return Math.round(clamped * 100); } /** @@ -149,27 +149,27 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num * or `null` when it can't be derived (see `computeExpectedCachePct`). */ export function viewExpectedCache(current: Usage, prev: Usage | null): CacheRateView | null { - const pct = computeExpectedCachePct(current, prev); - if (pct === null) return null; - return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; + const pct = computeExpectedCachePct(current, prev); + if (pct === null) return null; + return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 }; } /** Build a formatted view of a turn's aggregate metrics. */ export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView { - const total = totalTokens(turn.usage); - let totalGenMs: number | undefined; - for (const step of turn.steps) { - const stepMs = step.decodeMs ?? step.genTotalMs; - if (stepMs !== undefined) { - totalGenMs = (totalGenMs ?? 0) + stepMs; - } - } - const tps = computeTps(turn.usage.outputTokens, totalGenMs); - return { - label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", - tokensLabel: `${formatTokens(total)} tok`, - breakdown: formatBreakdown(turn.usage), - tps: formatTps(tps), - duration: formatDuration(turn.durationMs), - }; + const total = totalTokens(turn.usage); + let totalGenMs: number | undefined; + for (const step of turn.steps) { + const stepMs = step.decodeMs ?? step.genTotalMs; + if (stepMs !== undefined) { + totalGenMs = (totalGenMs ?? 0) + stepMs; + } + } + const tps = computeTps(turn.usage.outputTokens, totalGenMs); + return { + label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn", + tokensLabel: `${formatTokens(total)} tok`, + breakdown: formatBreakdown(turn.usage), + tps: formatTps(tps), + duration: formatDuration(turn.durationMs), + }; } diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts index 36cd96f..d3c9669 100644 --- a/src/core/metrics/index.ts +++ b/src/core/metrics/index.ts @@ -1,31 +1,31 @@ export { - type ContextUsage, - computeCachePct, - computeContextUsage, - computeExpectedCachePct, - computeTps, - formatCompactTokens, - formatContextSize, - viewCacheRate, - viewExpectedCache, - viewStepMetrics, - viewTurnMetrics, + type ContextUsage, + computeCachePct, + computeContextUsage, + computeExpectedCachePct, + computeTps, + formatCompactTokens, + formatContextSize, + viewCacheRate, + viewExpectedCache, + viewStepMetrics, + viewTurnMetrics, } from "./format"; export { interleaveTurnMetrics } from "./place"; export { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "./reducer"; export type { - CacheRateView, - MetricsRow, - MetricsState, - StepMetrics, - StepMetricsView, - TurnMetrics, - TurnMetricsEntry, - TurnMetricsView, + CacheRateView, + MetricsRow, + MetricsState, + StepMetrics, + StepMetricsView, + TurnMetrics, + TurnMetricsEntry, + TurnMetricsView, } from "./types"; diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts index 22f8639..c05ba3b 100644 --- a/src/core/metrics/place.test.ts +++ b/src/core/metrics/place.test.ts @@ -5,536 +5,536 @@ import { interleaveTurnMetrics } from "./place"; import type { MetricsRow, TurnMetricsEntry } from "./types"; function userGroup(seq: number, text: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "user", - chunk: { type: "text", text }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "user", + chunk: { type: "text", text }, + provisional: false, + }, + }; } function assistantGroup(seq: number, text: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { type: "text", text }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "assistant", + chunk: { type: "text", text }, + provisional: false, + }, + }; } function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "assistant", - chunk: { - type: "tool-call", - toolCallId, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId, + toolName: "test", + input: {}, + stepId: stepId as StepId, + }, + provisional: false, + }, + }; } function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup { - return { - kind: "single", - chunk: { - seq, - role: "tool", - chunk: { - type: "tool-result", - toolCallId, - toolName: "test", - content: "", - isError: false, - stepId: stepId as StepId, - }, - provisional: false, - }, - }; + return { + kind: "single", + chunk: { + seq, + role: "tool", + chunk: { + type: "tool-result", + toolCallId, + toolName: "test", + content: "", + isError: false, + stepId: stepId as StepId, + }, + provisional: false, + }, + }; } function toolBatchGroup(stepId: string, toolCallIds: string[]): RenderGroup { - return { - kind: "tool-batch", - stepId, - entries: toolCallIds.map((id) => ({ - call: { - type: "tool-call" as const, - toolCallId: id, - toolName: "test", - input: {}, - stepId: stepId as StepId, - }, - result: null, - })), - provisional: false, - }; + return { + kind: "tool-batch", + stepId, + entries: toolCallIds.map((id) => ({ + call: { + type: "tool-call" as const, + toolCallId: id, + toolName: "test", + input: {}, + stepId: stepId as StepId, + }, + result: null, + })), + provisional: false, + }; } function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics { - return { - stepId: stepId as StepId, - usage: { inputTokens, outputTokens }, - }; + return { + stepId: stepId as StepId, + usage: { inputTokens, outputTokens }, + }; } function makeTurn( - turnId: string, - inputTokens: number, - outputTokens: number, - steps: StepMetrics[] = [], + turnId: string, + inputTokens: number, + outputTokens: number, + steps: StepMetrics[] = [], ): TurnMetrics { - return { - turnId, - usage: { inputTokens, outputTokens }, - steps, - }; + return { + turnId, + usage: { inputTokens, outputTokens }, + steps, + }; } function makeEntry( - turnId: string, - inputTokens: number, - outputTokens: number, - steps: StepMetrics[] = [], + turnId: string, + inputTokens: number, + outputTokens: number, + steps: StepMetrics[] = [], ): TurnMetricsEntry { - return { - turnId, - steps, - total: makeTurn(turnId, inputTokens, outputTokens, steps), - }; + return { + turnId, + steps, + total: makeTurn(turnId, inputTokens, outputTokens, steps), + }; } function makeProgressiveEntry(turnId: string, steps: StepMetrics[]): TurnMetricsEntry { - return { - turnId, - steps, - total: null, - }; + return { + turnId, + steps, + total: null, + }; } function expectGroupAt( - rows: readonly { readonly kind: string }[], - index: number, - expected: RenderGroup, + rows: readonly { readonly kind: string }[], + index: number, + expected: RenderGroup, ): void { - const row = rows[index]; - expect(row?.kind).toBe("group"); - expect((row as { readonly group: RenderGroup } | undefined)?.group).toBe(expected); + const row = rows[index]; + expect(row?.kind).toBe("group"); + expect((row as { readonly group: RenderGroup } | undefined)?.group).toBe(expected); } function expectStepMetricsAt( - rows: readonly { readonly kind: string }[], - index: number, - expectedStepId: string, - expectedIndex: number, + rows: readonly { readonly kind: string }[], + index: number, + expectedStepId: string, + expectedIndex: number, ): void { - const row = rows[index]; - expect(row?.kind).toBe("step-metrics"); - const sm = row as { readonly step: StepMetrics; readonly index: number } | undefined; - expect(sm?.step.stepId).toBe(expectedStepId); - expect(sm?.index).toBe(expectedIndex); + const row = rows[index]; + expect(row?.kind).toBe("step-metrics"); + const sm = row as { readonly step: StepMetrics; readonly index: number } | undefined; + expect(sm?.step.stepId).toBe(expectedStepId); + expect(sm?.index).toBe(expectedIndex); } function expectTurnMetricsAt( - rows: readonly { readonly kind: string }[], - index: number, - expectedTurnId: string, + rows: readonly { readonly kind: string }[], + index: number, + expectedTurnId: string, ): void { - const row = rows[index]; - expect(row?.kind).toBe("turn-metrics"); - expect((row as { readonly turn: TurnMetrics } | undefined)?.turn.turnId).toBe(expectedTurnId); + const row = rows[index]; + expect(row?.kind).toBe("turn-metrics"); + expect((row as { readonly turn: TurnMetrics } | undefined)?.turn.turnId).toBe(expectedTurnId); } describe("interleaveTurnMetrics", () => { - it("no metrics: rows are all groups, unchanged order", () => { - const g1 = userGroup(1, "q"); - const g2 = assistantGroup(2, "a"); - const rows = interleaveTurnMetrics([g1, g2], []); - expect(rows).toHaveLength(2); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - }); - - it("head-aligned: segment i gets entries[i]", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = toolCallGroup(4, "s2", "c2"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - expectStepMetricsAt(rows, 6, "s2", 0); - expectTurnMetricsAt(rows, 7, "t2"); - }); - - it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = assistantGroup(4, "a2"); - const step = makeStep("s1", 100, 50); - const rows = interleaveTurnMetrics([g1, g2, g3, g4], [makeEntry("t1", 100, 50, [step])]); - - expect(rows).toHaveLength(6); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - }); - - it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("tool step anchors inline after its tool-batch group", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1", "c2"]); - const g3 = assistantGroup(3, "a1"); - const step0 = makeStep("t#0", 100, 50); - const step1 = makeStep("t#1", 200, 80); - const turn = makeEntry("t1", 300, 130, [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("single tool-call group anchors its step", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = assistantGroup(3, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("single tool-result group anchors its step", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolResultGroup(2, "s1", "c1"); - const g3 = assistantGroup(3, "a1"); - const step = makeStep("s1", 100, 50); - const turn = makeEntry("t1", 100, 50, [step]); - const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("multi-step: each tool step inline, unanchored text step skipped", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("t#0", ["c1"]); - const g3 = assistantGroup(2, "thinking"); - const g4 = toolBatchGroup("t#1", ["c2", "c3"]); - const g5 = assistantGroup(3, "a1"); - const step0 = makeStep("t#0", 100, 50); - const step1 = makeStep("t#1", 200, 80); - const step2 = makeStep("t#2", 50, 20); - const turn = makeEntry("t1", 350, 150, [step0, step1, step2]); - const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "t#0", 0); - expectGroupAt(rows, 3, g3); - expectGroupAt(rows, 4, g4); - expectStepMetricsAt(rows, 5, "t#1", 1); - expectGroupAt(rows, 6, g5); - expectTurnMetricsAt(rows, 7, "t1"); - }); - - it("multiple turns head-aligned with inline steps", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const g4 = userGroup(3, "q2"); - const g5 = toolCallGroup(4, "s2", "c2"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4, g5], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - expect(rows).toHaveLength(9); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - expectGroupAt(rows, 5, g4); - expectGroupAt(rows, 6, g5); - expectStepMetricsAt(rows, 7, "s2", 0); - expectTurnMetricsAt(rows, 8, "t2"); - }); - - it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step0 = makeStep("orphan", 100, 50); - const turn = makeEntry("t1", 100, 50, [step0]); - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("fewer metrics than segments: trailing segments are bare", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const g3 = userGroup(3, "q2"); - const g4 = assistantGroup(4, "a2"); - const g5 = userGroup(5, "q3"); - const g6 = assistantGroup(6, "a3"); - const step = makeStep("s1", 300, 120); - const rows = interleaveTurnMetrics( - [g1, g2, g3, g4, g5, g6], - [makeEntry("t1", 300, 120, [step])], - ); - - expect(rows).toHaveLength(8); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - expectGroupAt(rows, 4, g3); - expectGroupAt(rows, 5, g4); - expectGroupAt(rows, 6, g5); - expectGroupAt(rows, 7, g6); - }); - - it("in-flight turn (no durationMs) still produces turn row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const step = makeStep("s1", 100, 50); - const turn: TurnMetricsEntry = { - turnId: "t1", - steps: [step], - total: { - turnId: "t1", - usage: { inputTokens: 100, outputTokens: 50 }, - steps: [step], - }, - }; - const rows = interleaveTurnMetrics([g1, g2], [turn]); - - expect(rows).toHaveLength(4); - expectStepMetricsAt(rows, 2, "s1", 0); - expectTurnMetricsAt(rows, 3, "t1"); - const metricsRow = rows[3] as { readonly turn: TurnMetrics } | undefined; - expect(metricsRow?.turn.durationMs).toBeUndefined(); - }); - - it("leading non-turn groups emit as plain group rows", () => { - const g0 = assistantGroup(1, "system msg"); - const g1 = userGroup(2, "q1"); - const g2 = toolCallGroup(3, "s1", "c1"); - const step = makeStep("s1", 100, 50); - const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g0); - expect(rows[1]?.kind).toBe("group"); - expect(rows[2]?.kind).toBe("group"); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolCallGroup(2, "s1", "c1"); - const step1 = makeStep("s1", 100, 50); - const step2 = makeStep("s2", 200, 80); - const rows = interleaveTurnMetrics( - [g1, g2], - [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], - ); - - // Unmatched entry (t2) emits a standalone turn-metrics row at the top. - expect(rows).toHaveLength(5); - expectTurnMetricsAt(rows, 0, "t2"); - expectGroupAt(rows, 1, g1); - expectGroupAt(rows, 2, g2); - expectStepMetricsAt(rows, 3, "s1", 0); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("turn with no steps emits only turn-metrics (no step-metrics)", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const rows = interleaveTurnMetrics([g1, g2], [makeEntry("t1", 100, 50)]); - - expect(rows).toHaveLength(3); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectTurnMetricsAt(rows, 2, "t1"); - }); - - it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const step1 = makeStep("s1", 100, 50); - const entry = makeProgressiveEntry("t1", [step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); - - expect(rows).toHaveLength(4); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - }); - - it("entry with total emits step rows + a turn-metrics row", () => { - const g1 = userGroup(1, "q1"); - const g2 = toolBatchGroup("s1", ["c1"]); - const g3 = assistantGroup(2, "a1"); - const step1 = makeStep("s1", 100, 50); - const entry = makeEntry("t1", 100, 50, [step1]); - const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); - - expect(rows).toHaveLength(5); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - expectStepMetricsAt(rows, 2, "s1", 0); - expectGroupAt(rows, 3, g3); - expectTurnMetricsAt(rows, 4, "t1"); - }); - - it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => { - const g1 = userGroup(1, "q1"); - const g2 = assistantGroup(2, "a1"); - const step0 = makeStep("s1", 100, 50); - const step1 = makeStep("s2", 200, 80); - const entry = makeProgressiveEntry("t1", [step0, step1]); - const rows = interleaveTurnMetrics([g1, g2], [entry]); - - expect(rows).toHaveLength(2); - expectGroupAt(rows, 0, g1); - expectGroupAt(rows, 1, g2); - }); + it("no metrics: rows are all groups, unchanged order", () => { + const g1 = userGroup(1, "q"); + const g2 = assistantGroup(2, "a"); + const rows = interleaveTurnMetrics([g1, g2], []); + expect(rows).toHaveLength(2); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + }); + + it("head-aligned: segment i gets entries[i]", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = toolCallGroup(4, "s2", "c2"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + expectStepMetricsAt(rows, 6, "s2", 0); + expectTurnMetricsAt(rows, 7, "t2"); + }); + + it("a trailing segment with no entry (in-flight turn) renders no metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = assistantGroup(4, "a2"); + const step = makeStep("s1", 100, 50); + const rows = interleaveTurnMetrics([g1, g2, g3, g4], [makeEntry("t1", 100, 50, [step])]); + + expect(rows).toHaveLength(6); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + }); + + it("single text-only turn: no step row (unanchored), turn-metrics at tail", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("tool step anchors inline after its tool-batch group", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("t#0", ["c1", "c2"]); + const g3 = assistantGroup(3, "a1"); + const step0 = makeStep("t#0", 100, 50); + const step1 = makeStep("t#1", 200, 80); + const turn = makeEntry("t1", 300, 130, [step0, step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "t#0", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("single tool-call group anchors its step", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = assistantGroup(3, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("single tool-result group anchors its step", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolResultGroup(2, "s1", "c1"); + const g3 = assistantGroup(3, "a1"); + const step = makeStep("s1", 100, 50); + const turn = makeEntry("t1", 100, 50, [step]); + const rows = interleaveTurnMetrics([g1, g2, g3], [turn]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("multi-step: each tool step inline, unanchored text step skipped", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("t#0", ["c1"]); + const g3 = assistantGroup(2, "thinking"); + const g4 = toolBatchGroup("t#1", ["c2", "c3"]); + const g5 = assistantGroup(3, "a1"); + const step0 = makeStep("t#0", 100, 50); + const step1 = makeStep("t#1", 200, 80); + const step2 = makeStep("t#2", 50, 20); + const turn = makeEntry("t1", 350, 150, [step0, step1, step2]); + const rows = interleaveTurnMetrics([g1, g2, g3, g4, g5], [turn]); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "t#0", 0); + expectGroupAt(rows, 3, g3); + expectGroupAt(rows, 4, g4); + expectStepMetricsAt(rows, 5, "t#1", 1); + expectGroupAt(rows, 6, g5); + expectTurnMetricsAt(rows, 7, "t1"); + }); + + it("multiple turns head-aligned with inline steps", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const g4 = userGroup(3, "q2"); + const g5 = toolCallGroup(4, "s2", "c2"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4, g5], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + expect(rows).toHaveLength(9); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + expectGroupAt(rows, 5, g4); + expectGroupAt(rows, 6, g5); + expectStepMetricsAt(rows, 7, "s2", 0); + expectTurnMetricsAt(rows, 8, "t2"); + }); + + it("unanchored step (stepId not in groups) is skipped — only turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step0 = makeStep("orphan", 100, 50); + const turn = makeEntry("t1", 100, 50, [step0]); + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("fewer metrics than segments: trailing segments are bare", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const g3 = userGroup(3, "q2"); + const g4 = assistantGroup(4, "a2"); + const g5 = userGroup(5, "q3"); + const g6 = assistantGroup(6, "a3"); + const step = makeStep("s1", 300, 120); + const rows = interleaveTurnMetrics( + [g1, g2, g3, g4, g5, g6], + [makeEntry("t1", 300, 120, [step])], + ); + + expect(rows).toHaveLength(8); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + expectGroupAt(rows, 4, g3); + expectGroupAt(rows, 5, g4); + expectGroupAt(rows, 6, g5); + expectGroupAt(rows, 7, g6); + }); + + it("in-flight turn (no durationMs) still produces turn row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const step = makeStep("s1", 100, 50); + const turn: TurnMetricsEntry = { + turnId: "t1", + steps: [step], + total: { + turnId: "t1", + usage: { inputTokens: 100, outputTokens: 50 }, + steps: [step], + }, + }; + const rows = interleaveTurnMetrics([g1, g2], [turn]); + + expect(rows).toHaveLength(4); + expectStepMetricsAt(rows, 2, "s1", 0); + expectTurnMetricsAt(rows, 3, "t1"); + const metricsRow = rows[3] as { readonly turn: TurnMetrics } | undefined; + expect(metricsRow?.turn.durationMs).toBeUndefined(); + }); + + it("leading non-turn groups emit as plain group rows", () => { + const g0 = assistantGroup(1, "system msg"); + const g1 = userGroup(2, "q1"); + const g2 = toolCallGroup(3, "s1", "c1"); + const step = makeStep("s1", 100, 50); + const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g0); + expect(rows[1]?.kind).toBe("group"); + expect(rows[2]?.kind).toBe("group"); + expectStepMetricsAt(rows, 3, "s1", 0); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("more metrics than segments: unmatched entry emits standalone turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolCallGroup(2, "s1", "c1"); + const step1 = makeStep("s1", 100, 50); + const step2 = makeStep("s2", 200, 80); + const rows = interleaveTurnMetrics( + [g1, g2], + [makeEntry("t1", 100, 50, [step1]), makeEntry("t2", 200, 80, [step2])], + ); + + // Unmatched entry (t2) emits a standalone turn-metrics row at the top. + expect(rows).toHaveLength(5); + expectTurnMetricsAt(rows, 0, "t2"); + expectGroupAt(rows, 1, g1); + expectGroupAt(rows, 2, g2); + expectStepMetricsAt(rows, 3, "s1", 0); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("turn with no steps emits only turn-metrics (no step-metrics)", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const rows = interleaveTurnMetrics([g1, g2], [makeEntry("t1", 100, 50)]); + + expect(rows).toHaveLength(3); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectTurnMetricsAt(rows, 2, "t1"); + }); + + it("progressive: entry with steps but total=null emits step rows and NO turn-metrics row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const step1 = makeStep("s1", 100, 50); + const entry = makeProgressiveEntry("t1", [step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); + + expect(rows).toHaveLength(4); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + }); + + it("entry with total emits step rows + a turn-metrics row", () => { + const g1 = userGroup(1, "q1"); + const g2 = toolBatchGroup("s1", ["c1"]); + const g3 = assistantGroup(2, "a1"); + const step1 = makeStep("s1", 100, 50); + const entry = makeEntry("t1", 100, 50, [step1]); + const rows = interleaveTurnMetrics([g1, g2, g3], [entry]); + + expect(rows).toHaveLength(5); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + expectStepMetricsAt(rows, 2, "s1", 0); + expectGroupAt(rows, 3, g3); + expectTurnMetricsAt(rows, 4, "t1"); + }); + + it("progressive multi-step: unanchored steps skipped, no turn-metrics", () => { + const g1 = userGroup(1, "q1"); + const g2 = assistantGroup(2, "a1"); + const step0 = makeStep("s1", 100, 50); + const step1 = makeStep("s2", 200, 80); + const entry = makeProgressiveEntry("t1", [step0, step1]); + const rows = interleaveTurnMetrics([g1, g2], [entry]); + + expect(rows).toHaveLength(2); + expectGroupAt(rows, 0, g1); + expectGroupAt(rows, 1, g2); + }); }); describe("interleaveTurnMetrics — cumulative usage (cache total)", () => { - function turnMetricsRows(rows: readonly MetricsRow[]) { - return rows.filter((r): r is Extract => { - return r.kind === "turn-metrics"; - }); - } - - function cacheEntry( - turnId: string, - inputTokens: number, - outputTokens: number, - cacheReadTokens: number, - ): TurnMetricsEntry { - const total: TurnMetrics = { - turnId, - usage: { inputTokens, outputTokens, cacheReadTokens }, - steps: [], - }; - return { turnId, steps: [], total }; - } - - it("turn-metrics row carries this turn's usage and the running cumulative", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1")], - [makeEntry("t1", 1000, 100)], - ); - const tm = turnMetricsRows(rows); - expect(tm).toHaveLength(1); - expect(tm[0]?.turn.turnId).toBe("t1"); - expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); - }); - - it("accumulates cache read + input across turns (chat total)", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], - ); - const tm = turnMetricsRows(rows); - expect(tm).toHaveLength(2); - // turn 1: only its own usage - expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); - expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); - // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) - expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); - expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); - }); - - it("an in-flight (total=null) turn does not contribute to the cumulative", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], - ); - const tm = turnMetricsRows(rows); - // only the finalized turn emits a turn-metrics row; its cumulative is just itself - expect(tm).toHaveLength(1); - expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); - expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); - }); - - it("carries the prior finalized turn's usage as the retention baseline", () => { - const rows = interleaveTurnMetrics( - [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], - [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], - ); - const tm = turnMetricsRows(rows); - // first finalized turn has no earlier baseline - expect(tm[0]?.prevTurnUsage).toBeNull(); - // second turn's baseline is the first turn's usage - expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); - expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); - }); + function turnMetricsRows(rows: readonly MetricsRow[]) { + return rows.filter((r): r is Extract => { + return r.kind === "turn-metrics"; + }); + } + + function cacheEntry( + turnId: string, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number, + ): TurnMetricsEntry { + const total: TurnMetrics = { + turnId, + usage: { inputTokens, outputTokens, cacheReadTokens }, + steps: [], + }; + return { turnId, steps: [], total }; + } + + it("turn-metrics row carries this turn's usage and the running cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1")], + [makeEntry("t1", 1000, 100)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(1); + expect(tm[0]?.turn.turnId).toBe("t1"); + expect(tm[0]?.cumulativeUsage).toEqual({ inputTokens: 1000, outputTokens: 100 }); + }); + + it("accumulates cache read + input across turns (chat total)", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + expect(tm).toHaveLength(2); + // turn 1: only its own usage + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(2669); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(384); + // turn 2: sum of both (input 5406, cacheRead 2944 → matches the backend's 54% example) + expect(tm[1]?.cumulativeUsage.inputTokens).toBe(5406); + expect(tm[1]?.cumulativeUsage.cacheReadTokens).toBe(2944); + }); + + it("an in-flight (total=null) turn does not contribute to the cumulative", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 1000, 10, 500), makeProgressiveEntry("t2", [makeStep("s1", 200, 5)])], + ); + const tm = turnMetricsRows(rows); + // only the finalized turn emits a turn-metrics row; its cumulative is just itself + expect(tm).toHaveLength(1); + expect(tm[0]?.cumulativeUsage.inputTokens).toBe(1000); + expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500); + }); + + it("carries the prior finalized turn's usage as the retention baseline", () => { + const rows = interleaveTurnMetrics( + [userGroup(1, "q1"), assistantGroup(2, "a1"), userGroup(3, "q2"), assistantGroup(4, "a2")], + [cacheEntry("t1", 2669, 10, 384), cacheEntry("t2", 2737, 10, 2560)], + ); + const tm = turnMetricsRows(rows); + // first finalized turn has no earlier baseline + expect(tm[0]?.prevTurnUsage).toBeNull(); + // second turn's baseline is the first turn's usage + expect(tm[1]?.prevTurnUsage?.inputTokens).toBe(2669); + expect(tm[1]?.prevTurnUsage?.cacheReadTokens).toBe(384); + }); }); diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts index 0048fa0..b165fd0 100644 --- a/src/core/metrics/place.ts +++ b/src/core/metrics/place.ts @@ -3,22 +3,22 @@ import type { RenderGroup } from "../chunks"; import type { MetricsRow, TurnMetricsEntry } from "./types"; function groupStepId(g: RenderGroup): string | undefined { - if (g.kind === "tool-batch") return g.stepId; - const c = g.chunk.chunk; - return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; + if (g.kind === "tool-batch") return g.stepId; + const c = g.chunk.chunk; + return c.type === "tool-call" || c.type === "tool-result" ? c.stepId : undefined; } /** Element-wise sum of two token usages (cache fields included only when nonzero). */ function addUsage(a: Usage, b: Usage): Usage { - const out: Usage = { - inputTokens: a.inputTokens + b.inputTokens, - outputTokens: a.outputTokens + b.outputTokens, - }; - const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); - const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); - if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; - if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; - return out; + const out: Usage = { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + }; + const read = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0); + const write = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0); + if (read > 0) (out as { cacheReadTokens?: number }).cacheReadTokens = read; + if (write > 0) (out as { cacheWriteTokens?: number }).cacheWriteTokens = write; + return out; } /** @@ -49,243 +49,243 @@ function addUsage(a: Usage, b: Usage): Usage { * of which turns were trimmed. */ export function interleaveTurnMetrics( - groups: readonly RenderGroup[], - entries: readonly TurnMetricsEntry[], + groups: readonly RenderGroup[], + entries: readonly TurnMetricsEntry[], ): readonly MetricsRow[] { - if (entries.length === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } + if (entries.length === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); + } - const segmentStarts: number[] = []; - for (let i = 0; i < groups.length; i++) { - const g = groups[i]; - if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { - segmentStarts.push(i); - } - } + const segmentStarts: number[] = []; + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + if (g !== undefined && g.kind === "single" && g.chunk.role === "user") { + segmentStarts.push(i); + } + } - let T = segmentStarts.length; + let T = segmentStarts.length; - // No user messages — e.g. a compacted conversation whose history starts - // with a system summary. Treat the entire transcript as one segment so - // turn/step metrics can still be placed. - if (T === 0 && entries.length > 0) { - segmentStarts.push(0); - T = 1; - } + // No user messages — e.g. a compacted conversation whose history starts + // with a system summary. Treat the entire transcript as one segment so + // turn/step metrics can still be placed. + if (T === 0 && entries.length > 0) { + segmentStarts.push(0); + T = 1; + } - if (T === 0) { - return groups.map((g) => ({ kind: "group" as const, group: g })); - } + if (T === 0) { + return groups.map((g) => ({ kind: "group" as const, group: g })); + } - const K = entries.length; + const K = entries.length; - // Build stepId → entry-index lookup for matching. - const entryStepIds: Set[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); + // Build stepId → entry-index lookup for matching. + const entryStepIds: Set[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId))); - // Match segments to entries. Pass 1: match by stepId overlap (handles - // trimming where head-alignment would be wrong). Pass 2: sequential fallback - // for unmatched segments (text-only turns with no stepId-bearing groups). - const usedEntries = new Set(); - const segmentEntry = new Map(); - const segmentEntryIndex = new Map(); + // Match segments to entries. Pass 1: match by stepId overlap (handles + // trimming where head-alignment would be wrong). Pass 2: sequential fallback + // for unmatched segments (text-only turns with no stepId-bearing groups). + const usedEntries = new Set(); + const segmentEntry = new Map(); + const segmentEntryIndex = new Map(); - // Pass 1: stepId matching. - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; + // Pass 1: stepId matching. + for (let seg = 0; seg < T; seg++) { + const start = segmentStarts[seg] ?? 0; + const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - const segStepIds = new Set(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) segStepIds.add(sid); - } - if (segStepIds.size === 0) continue; // text-only — defer to pass 2 + const segStepIds = new Set(); + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g === undefined) continue; + const sid = groupStepId(g); + if (sid !== undefined) segStepIds.add(sid); + } + if (segStepIds.size === 0) continue; // text-only — defer to pass 2 - let bestEntry = -1; - let bestMatch = 0; - for (let i = 0; i < K; i++) { - if (usedEntries.has(i)) continue; - let match = 0; - for (const sid of segStepIds) { - if (entryStepIds[i]?.has(sid)) match++; - } - if (match > bestMatch) { - bestMatch = match; - bestEntry = i; - } - } - if (bestEntry >= 0) { - usedEntries.add(bestEntry); - const e = entries[bestEntry]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, bestEntry); - } - } - } + let bestEntry = -1; + let bestMatch = 0; + for (let i = 0; i < K; i++) { + if (usedEntries.has(i)) continue; + let match = 0; + for (const sid of segStepIds) { + if (entryStepIds[i]?.has(sid)) match++; + } + if (match > bestMatch) { + bestMatch = match; + bestEntry = i; + } + } + if (bestEntry >= 0) { + usedEntries.add(bestEntry); + const e = entries[bestEntry]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, bestEntry); + } + } + } - // Pass 2: sequential fallback for unmatched segments. - // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT: - // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the - // newest and trims the oldest), so match the LAST T entries to the T - // segments. This prevents misaligning oldest (trimmed) entries to newest - // segments — which would show "turn 1" on turn 20's content. - const pass1Matches = segmentEntry.size; - if (pass1Matches === 0 && K >= T) { - // Tail-align: skip the first K-T entries (trimmed turns). - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - const entryIdx = K - T + seg; - if (entryIdx < K && !usedEntries.has(entryIdx)) { - usedEntries.add(entryIdx); - const e = entries[entryIdx]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, entryIdx); - } - } - } - } else { - // Head-align fallback for remaining unmatched segments. - let nextUnused = 0; - for (let seg = 0; seg < T; seg++) { - if (segmentEntry.has(seg)) continue; - while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; - if (nextUnused < K) { - usedEntries.add(nextUnused); - const e = entries[nextUnused]; - if (e !== undefined) { - segmentEntry.set(seg, e); - segmentEntryIndex.set(seg, nextUnused); - } - nextUnused++; - } - } - } + // Pass 2: sequential fallback for unmatched segments. + // If NO segments were matched by stepId (pass 1), use TAIL-ALIGNMENT: + // the loaded chunks are always the NEWEST (chat-limit/windowing keeps the + // newest and trims the oldest), so match the LAST T entries to the T + // segments. This prevents misaligning oldest (trimmed) entries to newest + // segments — which would show "turn 1" on turn 20's content. + const pass1Matches = segmentEntry.size; + if (pass1Matches === 0 && K >= T) { + // Tail-align: skip the first K-T entries (trimmed turns). + for (let seg = 0; seg < T; seg++) { + if (segmentEntry.has(seg)) continue; + const entryIdx = K - T + seg; + if (entryIdx < K && !usedEntries.has(entryIdx)) { + usedEntries.add(entryIdx); + const e = entries[entryIdx]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, entryIdx); + } + } + } + } else { + // Head-align fallback for remaining unmatched segments. + let nextUnused = 0; + for (let seg = 0; seg < T; seg++) { + if (segmentEntry.has(seg)) continue; + while (nextUnused < K && usedEntries.has(nextUnused)) nextUnused++; + if (nextUnused < K) { + usedEntries.add(nextUnused); + const e = entries[nextUnused]; + if (e !== undefined) { + segmentEntry.set(seg, e); + segmentEntryIndex.set(seg, nextUnused); + } + nextUnused++; + } + } + } - // Running cumulative usage across ALL finalized turns (in entry order), for - // the per-turn "chat total" cache rate. Alongside it, the previous finalized - // turn's usage at each index — the baseline for cross-turn retention. - const cumulativeByEntry: Usage[] = []; - const prevUsageByEntry: (Usage | null)[] = []; - let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; - let lastFinalizedUsage: Usage | null = null; - for (const e of entries) { - prevUsageByEntry.push(lastFinalizedUsage); - if (e.total !== null) { - runningUsage = addUsage(runningUsage, e.total.usage); - lastFinalizedUsage = e.total.usage; - } - cumulativeByEntry.push(runningUsage); - } + // Running cumulative usage across ALL finalized turns (in entry order), for + // the per-turn "chat total" cache rate. Alongside it, the previous finalized + // turn's usage at each index — the baseline for cross-turn retention. + const cumulativeByEntry: Usage[] = []; + const prevUsageByEntry: (Usage | null)[] = []; + let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 }; + let lastFinalizedUsage: Usage | null = null; + for (const e of entries) { + prevUsageByEntry.push(lastFinalizedUsage); + if (e.total !== null) { + runningUsage = addUsage(runningUsage, e.total.usage); + lastFinalizedUsage = e.total.usage; + } + cumulativeByEntry.push(runningUsage); + } - const rows: MetricsRow[] = []; + const rows: MetricsRow[] = []; - const firstUserIdx = segmentStarts[0] ?? 0; + const firstUserIdx = segmentStarts[0] ?? 0; - // Emit turn-metrics rows for entries that weren't matched to any segment - // (fully trimmed turns — their content was unloaded by the chat limit, but - // their aggregate metrics still show so the user knows what was trimmed). - for (let i = 0; i < entries.length; i++) { - if (usedEntries.has(i)) continue; - const e = entries[i]; - if (e === undefined || e.total === null) continue; - rows.push({ - kind: "turn-metrics", - turn: e.total, - turnNumber: i + 1, - cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage, - prevTurnUsage: prevUsageByEntry[i] ?? null, - }); - } + // Emit turn-metrics rows for entries that weren't matched to any segment + // (fully trimmed turns — their content was unloaded by the chat limit, but + // their aggregate metrics still show so the user knows what was trimmed). + for (let i = 0; i < entries.length; i++) { + if (usedEntries.has(i)) continue; + const e = entries[i]; + if (e === undefined || e.total === null) continue; + rows.push({ + kind: "turn-metrics", + turn: e.total, + turnNumber: i + 1, + cumulativeUsage: cumulativeByEntry[i] ?? e.total.usage, + prevTurnUsage: prevUsageByEntry[i] ?? null, + }); + } - for (let i = 0; i < firstUserIdx; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } + for (let i = 0; i < firstUserIdx; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + } - for (let seg = 0; seg < T; seg++) { - const start = segmentStarts[seg] ?? 0; - const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; + for (let seg = 0; seg < T; seg++) { + const start = segmentStarts[seg] ?? 0; + const end = seg + 1 < T ? (segmentStarts[seg + 1] ?? groups.length) : groups.length; - const entry = segmentEntry.get(seg); + const entry = segmentEntry.get(seg); - if (entry === undefined) { - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - } - continue; - } + if (entry === undefined) { + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + } + continue; + } - const entryIdx = segmentEntryIndex.get(seg) ?? 0; + const entryIdx = segmentEntryIndex.get(seg) ?? 0; - // Build anchor map: for each stepId, the LAST group index in this segment. - const anchorByStepId = new Map(); - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g === undefined) continue; - const sid = groupStepId(g); - if (sid !== undefined) { - anchorByStepId.set(sid, i); - } - } + // Build anchor map: for each stepId, the LAST group index in this segment. + const anchorByStepId = new Map(); + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g === undefined) continue; + const sid = groupStepId(g); + if (sid !== undefined) { + anchorByStepId.set(sid, i); + } + } - // Classify each step as anchored or unanchored. Unanchored steps - // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — - // step-metrics are only shown inline next to the content they describe. - const anchored: Map = - new Map(); + // Classify each step as anchored or unanchored. Unanchored steps + // (content trimmed, or text-only steps with no tool chunks) are SKIPPED — + // step-metrics are only shown inline next to the content they describe. + const anchored: Map = + new Map(); - for (let i = 0; i < entry.steps.length; i++) { - const step = entry.steps[i]; - if (step === undefined) continue; - const anchorGroupIdx = anchorByStepId.get(step.stepId); - if (anchorGroupIdx !== undefined) { - let arr = anchored.get(anchorGroupIdx); - if (arr === undefined) { - arr = []; - anchored.set(anchorGroupIdx, arr); - } - arr.push({ stepIndex: i, step }); - } - // Unanchored steps (no matching group) are skipped — no tail bubbles. - } + for (let i = 0; i < entry.steps.length; i++) { + const step = entry.steps[i]; + if (step === undefined) continue; + const anchorGroupIdx = anchorByStepId.get(step.stepId); + if (anchorGroupIdx !== undefined) { + let arr = anchored.get(anchorGroupIdx); + if (arr === undefined) { + arr = []; + anchored.set(anchorGroupIdx, arr); + } + arr.push({ stepIndex: i, step }); + } + // Unanchored steps (no matching group) are skipped — no tail bubbles. + } - // Emit groups; after each anchored group, emit its step-metrics rows. - for (let i = start; i < end; i++) { - const g = groups[i]; - if (g !== undefined) { - rows.push({ kind: "group", group: g }); - } - const stepsHere = anchored.get(i); - if (stepsHere !== undefined) { - stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); - for (const { step, stepIndex } of stepsHere) { - rows.push({ kind: "step-metrics", step, index: stepIndex }); - } - } - } + // Emit groups; after each anchored group, emit its step-metrics rows. + for (let i = start; i < end; i++) { + const g = groups[i]; + if (g !== undefined) { + rows.push({ kind: "group", group: g }); + } + const stepsHere = anchored.get(i); + if (stepsHere !== undefined) { + stepsHere.sort((a, b) => a.stepIndex - b.stepIndex); + for (const { step, stepIndex } of stepsHere) { + rows.push({ kind: "step-metrics", step, index: stepIndex }); + } + } + } - // Turn-metrics row (only when the turn is finalized). Unanchored steps - // are skipped — no tail bubbles. - if (entry.total !== null) { - rows.push({ - kind: "turn-metrics", - turn: entry.total, - turnNumber: entryIdx + 1, - cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, - prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, - }); - } - } + // Turn-metrics row (only when the turn is finalized). Unanchored steps + // are skipped — no tail bubbles. + if (entry.total !== null) { + rows.push({ + kind: "turn-metrics", + turn: entry.total, + turnNumber: entryIdx + 1, + cumulativeUsage: cumulativeByEntry[entryIdx] ?? entry.total.usage, + prevTurnUsage: prevUsageByEntry[entryIdx] ?? null, + }); + } + } - return rows; + return rows; } diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts index cd9f673..7d0a270 100644 --- a/src/core/metrics/reducer.test.ts +++ b/src/core/metrics/reducer.test.ts @@ -1,442 +1,442 @@ import type { StepId, TurnDoneEvent, TurnStepCompleteEvent, TurnUsageEvent } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - applyDurableMetrics, - foldMetricsEvent, - initialMetricsState, - selectCurrentContextSize, - selectOrderedTurnMetrics, + applyDurableMetrics, + foldMetricsEvent, + initialMetricsState, + selectCurrentContextSize, + selectOrderedTurnMetrics, } from "./reducer"; const usageEvent = ( - turnId: string, - inputTokens: number, - outputTokens: number, - stepId?: string, + turnId: string, + inputTokens: number, + outputTokens: number, + stepId?: string, ): TurnUsageEvent => { - const base = { - type: "usage" as const, - conversationId: "c1", - turnId, - usage: { inputTokens, outputTokens }, - }; - if (stepId !== undefined) { - return { ...base, stepId: stepId as StepId }; - } - return base; + const base = { + type: "usage" as const, + conversationId: "c1", + turnId, + usage: { inputTokens, outputTokens }, + }; + if (stepId !== undefined) { + return { ...base, stepId: stepId as StepId }; + } + return base; }; const stepCompleteEvent = ( - turnId: string, - stepId: string, - timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {}, + turnId: string, + stepId: string, + timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {}, ): TurnStepCompleteEvent => ({ - type: "step-complete", - conversationId: "c1", - turnId, - stepId: stepId as StepId, - ...timing, + type: "step-complete", + conversationId: "c1", + turnId, + stepId: stepId as StepId, + ...timing, }); const doneEvent = ( - turnId: string, - extra: { - durationMs?: number; - usage?: { inputTokens: number; outputTokens: number }; - contextSize?: number; - } = {}, + turnId: string, + extra: { + durationMs?: number; + usage?: { inputTokens: number; outputTokens: number }; + contextSize?: number; + } = {}, ): TurnDoneEvent => ({ - type: "done", - conversationId: "c1", - turnId, - reason: "stop", - ...extra, + type: "done", + conversationId: "c1", + turnId, + reason: "stop", + ...extra, }); describe("initialMetricsState", () => { - it("starts empty", () => { - const s = initialMetricsState(); - expect(s.live.size).toBe(0); - expect(s.liveOrder).toEqual([]); - expect(s.durable.size).toBe(0); - expect(s.durableOrder).toEqual([]); - }); + it("starts empty", () => { + const s = initialMetricsState(); + expect(s.live.size).toBe(0); + expect(s.liveOrder).toEqual([]); + expect(s.durable.size).toBe(0); + expect(s.durableOrder).toEqual([]); + }); }); describe("foldMetricsEvent", () => { - it("folds per-step usage by stepId into a turn", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.steps).toHaveLength(2); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.steps[0]?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); - expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); - expect(ordered[0]?.steps[1]?.usage).toEqual({ inputTokens: 200, outputTokens: 80 }); - }); - - it("folds step-complete timing and merges with same-step usage", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent( - s, - stepCompleteEvent("t1", "s1", { ttftMs: 200, decodeMs: 800, genTotalMs: 1000 }), - ); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - const step = ordered[0]?.steps[0]; - expect(step?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); - expect(step?.ttftMs).toBe(200); - expect(step?.decodeMs).toBe(800); - expect(step?.genTotalMs).toBe(1000); - }); - - it("step-complete before usage defaults usage to zeros", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - const step = ordered[0]?.steps[0]; - expect(step?.usage).toEqual({ inputTokens: 0, outputTokens: 0 }); - expect(step?.genTotalMs).toBe(500); - }); - - it("done sets durationMs and aggregate usage", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent( - s, - doneEvent("t1", { - durationMs: 5000, - usage: { inputTokens: 300, outputTokens: 150 }, - }), - ); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.durationMs).toBe(5000); - expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 150 }); - }); - - it("aggregate usage sums steps when done.usage absent", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); - }); - - it("aggregate usage includes cache only when a step had cache", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, { - type: "usage", - conversationId: "c1", - turnId: "t1", - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 30 }, - }); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.usage.cacheReadTokens).toBe(30); - expect(ordered[0]?.total?.usage.cacheWriteTokens).toBeUndefined(); - }); - - it("tolerates missing clock (no genTotalMs/ttft/decode)", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - const step = ordered[0]?.steps[0]; - expect(step?.ttftMs).toBeUndefined(); - expect(step?.decodeMs).toBeUndefined(); - expect(step?.genTotalMs).toBeUndefined(); - expect(ordered[0]?.total?.durationMs).toBeUndefined(); - }); - - it("usage without stepId does not create a turn", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50)); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(0); - }); - - it("ignores non-metrics events", () => { - const s = initialMetricsState(); - const next = foldMetricsEvent(s, { - type: "status", - conversationId: "c1", - status: "running", - }); - expect(next).toBe(s); - }); - - it("preserves first-seen order of steps", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 10, 5, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.steps[0]?.stepId).toBe("s2"); - expect(ordered[0]?.steps[1]?.stepId).toBe("s1"); - }); - - it("preserves first-seen order of turns", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t2", 10, 5, "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); - s = foldMetricsEvent(s, doneEvent("t2")); - s = foldMetricsEvent(s, doneEvent("t1")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.turnId).toBe("t2"); - expect(ordered[1]?.turnId).toBe("t1"); - }); + it("folds per-step usage by stepId into a turn", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.steps).toHaveLength(2); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.steps[0]?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); + expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); + expect(ordered[0]?.steps[1]?.usage).toEqual({ inputTokens: 200, outputTokens: 80 }); + }); + + it("folds step-complete timing and merges with same-step usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent( + s, + stepCompleteEvent("t1", "s1", { ttftMs: 200, decodeMs: 800, genTotalMs: 1000 }), + ); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + const step = ordered[0]?.steps[0]; + expect(step?.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); + expect(step?.ttftMs).toBe(200); + expect(step?.decodeMs).toBe(800); + expect(step?.genTotalMs).toBe(1000); + }); + + it("step-complete before usage defaults usage to zeros", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + const step = ordered[0]?.steps[0]; + expect(step?.usage).toEqual({ inputTokens: 0, outputTokens: 0 }); + expect(step?.genTotalMs).toBe(500); + }); + + it("done sets durationMs and aggregate usage", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent( + s, + doneEvent("t1", { + durationMs: 5000, + usage: { inputTokens: 300, outputTokens: 150 }, + }), + ); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.durationMs).toBe(5000); + expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 150 }); + }); + + it("aggregate usage sums steps when done.usage absent", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); + }); + + it("aggregate usage includes cache only when a step had cache", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, { + type: "usage", + conversationId: "c1", + turnId: "t1", + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 30 }, + }); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.usage.cacheReadTokens).toBe(30); + expect(ordered[0]?.total?.usage.cacheWriteTokens).toBeUndefined(); + }); + + it("tolerates missing clock (no genTotalMs/ttft/decode)", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + const step = ordered[0]?.steps[0]; + expect(step?.ttftMs).toBeUndefined(); + expect(step?.decodeMs).toBeUndefined(); + expect(step?.genTotalMs).toBeUndefined(); + expect(ordered[0]?.total?.durationMs).toBeUndefined(); + }); + + it("usage without stepId does not create a turn", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50)); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(0); + }); + + it("ignores non-metrics events", () => { + const s = initialMetricsState(); + const next = foldMetricsEvent(s, { + type: "status", + conversationId: "c1", + status: "running", + }); + expect(next).toBe(s); + }); + + it("preserves first-seen order of steps", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 10, 5, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.steps[0]?.stepId).toBe("s2"); + expect(ordered[0]?.steps[1]?.stepId).toBe("s1"); + }); + + it("preserves first-seen order of turns", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t2", 10, 5, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 20, 8, "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + s = foldMetricsEvent(s, doneEvent("t1")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.turnId).toBe("t2"); + expect(ordered[1]?.turnId).toBe("t1"); + }); }); describe("selectOrderedTurnMetrics", () => { - it("durable wins over live by turnId, live-done appended last", () => { - let s = initialMetricsState(); - - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, usageEvent("t2", 200, 80, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); - s = foldMetricsEvent(s, doneEvent("t2")); - - s = applyDurableMetrics(s, [ - { - turnId: "t1", - usage: { inputTokens: 999, outputTokens: 999 }, - durationMs: 3000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 999, outputTokens: 999 }, - genTotalMs: 3000, - }, - ], - }, - ]); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(2); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.total?.usage.inputTokens).toBe(999); - expect(ordered[0]?.total?.durationMs).toBe(3000); - expect(ordered[1]?.turnId).toBe("t2"); - expect(ordered[1]?.total?.durationMs).toBeUndefined(); - }); - - it("empty state returns empty", () => { - const s = initialMetricsState(); - expect(selectOrderedTurnMetrics(s)).toEqual([]); - }); - - it("selectOrderedTurnMetrics: in-flight turn exposes only completed steps and total=null", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.steps).toHaveLength(1); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.total).toBeNull(); - }); - - it("selectOrderedTurnMetrics: a turn with no complete step and not done is omitted", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(0); - }); - - it("selectOrderedTurnMetrics: after done, total is present", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); - s = foldMetricsEvent(s, doneEvent("t1", { durationMs: 2000 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.total?.durationMs).toBe(2000); - expect(ordered[0]?.steps).toHaveLength(1); - }); - - it("step-complete marks the step complete", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.steps).toHaveLength(1); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.steps[0]?.genTotalMs).toBe(500); - }); - - it("selectOrderedTurnMetrics: durable turn → steps + total present", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { - turnId: "t1", - usage: { inputTokens: 300, outputTokens: 150 }, - durationMs: 5000, - steps: [ - { - stepId: "s1" as StepId, - usage: { inputTokens: 100, outputTokens: 50 }, - genTotalMs: 1000, - }, - { - stepId: "s2" as StepId, - usage: { inputTokens: 200, outputTokens: 100 }, - genTotalMs: 2000, - }, - ], - }, - ]); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered).toHaveLength(1); - expect(ordered[0]?.turnId).toBe("t1"); - expect(ordered[0]?.steps).toHaveLength(2); - expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); - expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); - expect(ordered[0]?.total?.usage.inputTokens).toBe(300); - expect(ordered[0]?.total?.durationMs).toBe(5000); - }); + it("durable wins over live by turnId, live-done appended last", () => { + let s = initialMetricsState(); + + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, usageEvent("t2", 200, 80, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1")); + s = foldMetricsEvent(s, doneEvent("t2")); + + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 999, outputTokens: 999 }, + durationMs: 3000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 999, outputTokens: 999 }, + genTotalMs: 3000, + }, + ], + }, + ]); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(2); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.total?.usage.inputTokens).toBe(999); + expect(ordered[0]?.total?.durationMs).toBe(3000); + expect(ordered[1]?.turnId).toBe("t2"); + expect(ordered[1]?.total?.durationMs).toBeUndefined(); + }); + + it("empty state returns empty", () => { + const s = initialMetricsState(); + expect(selectOrderedTurnMetrics(s)).toEqual([]); + }); + + it("selectOrderedTurnMetrics: in-flight turn exposes only completed steps and total=null", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.steps).toHaveLength(1); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.total).toBeNull(); + }); + + it("selectOrderedTurnMetrics: a turn with no complete step and not done is omitted", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(0); + }); + + it("selectOrderedTurnMetrics: after done, total is present", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 1000 })); + s = foldMetricsEvent(s, doneEvent("t1", { durationMs: 2000 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.total?.durationMs).toBe(2000); + expect(ordered[0]?.steps).toHaveLength(1); + }); + + it("step-complete marks the step complete", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1", { genTotalMs: 500 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.steps).toHaveLength(1); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.steps[0]?.genTotalMs).toBe(500); + }); + + it("selectOrderedTurnMetrics: durable turn → steps + total present", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { + turnId: "t1", + usage: { inputTokens: 300, outputTokens: 150 }, + durationMs: 5000, + steps: [ + { + stepId: "s1" as StepId, + usage: { inputTokens: 100, outputTokens: 50 }, + genTotalMs: 1000, + }, + { + stepId: "s2" as StepId, + usage: { inputTokens: 200, outputTokens: 100 }, + genTotalMs: 2000, + }, + ], + }, + ]); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered).toHaveLength(1); + expect(ordered[0]?.turnId).toBe("t1"); + expect(ordered[0]?.steps).toHaveLength(2); + expect(ordered[0]?.steps[0]?.stepId).toBe("s1"); + expect(ordered[0]?.steps[1]?.stepId).toBe("s2"); + expect(ordered[0]?.total?.usage.inputTokens).toBe(300); + expect(ordered[0]?.total?.durationMs).toBe(5000); + }); }); describe("applyDurableMetrics", () => { - it("stores durable turns in order", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, - { turnId: "t2", usage: { inputTokens: 20, outputTokens: 8 }, steps: [] }, - ]); - expect(s.durableOrder).toEqual(["t1", "t2"]); - expect(s.durable.size).toBe(2); - }); - - it("is idempotent for same turnId", () => { - let s = initialMetricsState(); - const turn = { - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 5 }, - steps: [], - }; - s = applyDurableMetrics(s, [turn]); - s = applyDurableMetrics(s, [turn]); - expect(s.durableOrder).toEqual(["t1"]); - expect(s.durable.size).toBe(1); - }); - - it("overwrites durable turn data for same turnId", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, - ]); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 99, outputTokens: 99 }, steps: [] }, - ]); - expect(s.durable.get("t1")?.usage.inputTokens).toBe(99); - }); + it("stores durable turns in order", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, + { turnId: "t2", usage: { inputTokens: 20, outputTokens: 8 }, steps: [] }, + ]); + expect(s.durableOrder).toEqual(["t1", "t2"]); + expect(s.durable.size).toBe(2); + }); + + it("is idempotent for same turnId", () => { + let s = initialMetricsState(); + const turn = { + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 5 }, + steps: [], + }; + s = applyDurableMetrics(s, [turn]); + s = applyDurableMetrics(s, [turn]); + expect(s.durableOrder).toEqual(["t1"]); + expect(s.durable.size).toBe(1); + }); + + it("overwrites durable turn data for same turnId", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [] }, + ]); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 99, outputTokens: 99 }, steps: [] }, + ]); + expect(s.durable.get("t1")?.usage.inputTokens).toBe(99); + }); }); describe("contextSize / selectCurrentContextSize", () => { - it("live done carries contextSize onto the turn total", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 1234 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.contextSize).toBe(1234); - expect(selectCurrentContextSize(s)).toBe(1234); - }); - - it("contextSize is NOT the aggregate usage sum (multi-step turn)", () => { - let s = initialMetricsState(); - // Two steps: usage sums to 300 in / 130 out = 430, but contextSize is the - // backend-stamped final-step occupancy, independent of the sum. - s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); - s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); - s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 250 })); - - const ordered = selectOrderedTurnMetrics(s); - expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); - expect(ordered[0]?.total?.contextSize).toBe(250); - expect(selectCurrentContextSize(s)).toBe(250); - }); - - it("persisted (durable) contextSize is preserved and selected", () => { - let s = initialMetricsState(); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [], contextSize: 4096 }, - ]); - expect(s.durable.get("t1")?.contextSize).toBe(4096); - expect(selectCurrentContextSize(s)).toBe(4096); - }); - - it("selectCurrentContextSize returns the LATEST turn's value", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 100 })); - s = foldMetricsEvent(s, doneEvent("t2", { contextSize: 900 })); - expect(selectCurrentContextSize(s)).toBe(900); - }); - - it("selectCurrentContextSize skips a later turn that lacks contextSize", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); - // t2 finishes but the provider reported no per-step usage → no contextSize. - s = foldMetricsEvent(s, doneEvent("t2")); - expect(selectCurrentContextSize(s)).toBe(700); - }); - - it("selectCurrentContextSize is undefined (not 0) when nothing reported", () => { - let s = initialMetricsState(); - expect(selectCurrentContextSize(s)).toBeUndefined(); - s = foldMetricsEvent(s, doneEvent("t1")); - expect(selectCurrentContextSize(s)).toBeUndefined(); - }); - - it("durable contextSize wins over live for a shared turnId", () => { - let s = initialMetricsState(); - s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); - s = applyDurableMetrics(s, [ - { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, - ]); - expect(selectCurrentContextSize(s)).toBe(222); - }); + it("live done carries contextSize onto the turn total", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 1234 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.contextSize).toBe(1234); + expect(selectCurrentContextSize(s)).toBe(1234); + }); + + it("contextSize is NOT the aggregate usage sum (multi-step turn)", () => { + let s = initialMetricsState(); + // Two steps: usage sums to 300 in / 130 out = 430, but contextSize is the + // backend-stamped final-step occupancy, independent of the sum. + s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1")); + s = foldMetricsEvent(s, usageEvent("t1", 200, 80, "s2")); + s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2")); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 250 })); + + const ordered = selectOrderedTurnMetrics(s); + expect(ordered[0]?.total?.usage).toEqual({ inputTokens: 300, outputTokens: 130 }); + expect(ordered[0]?.total?.contextSize).toBe(250); + expect(selectCurrentContextSize(s)).toBe(250); + }); + + it("persisted (durable) contextSize is preserved and selected", () => { + let s = initialMetricsState(); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [], contextSize: 4096 }, + ]); + expect(s.durable.get("t1")?.contextSize).toBe(4096); + expect(selectCurrentContextSize(s)).toBe(4096); + }); + + it("selectCurrentContextSize returns the LATEST turn's value", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 100 })); + s = foldMetricsEvent(s, doneEvent("t2", { contextSize: 900 })); + expect(selectCurrentContextSize(s)).toBe(900); + }); + + it("selectCurrentContextSize skips a later turn that lacks contextSize", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 })); + // t2 finishes but the provider reported no per-step usage → no contextSize. + s = foldMetricsEvent(s, doneEvent("t2")); + expect(selectCurrentContextSize(s)).toBe(700); + }); + + it("selectCurrentContextSize is undefined (not 0) when nothing reported", () => { + let s = initialMetricsState(); + expect(selectCurrentContextSize(s)).toBeUndefined(); + s = foldMetricsEvent(s, doneEvent("t1")); + expect(selectCurrentContextSize(s)).toBeUndefined(); + }); + + it("durable contextSize wins over live for a shared turnId", () => { + let s = initialMetricsState(); + s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 })); + s = applyDurableMetrics(s, [ + { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 }, + ]); + expect(selectCurrentContextSize(s)).toBe(222); + }); }); diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts index 1e66cc8..bebef1d 100644 --- a/src/core/metrics/reducer.ts +++ b/src/core/metrics/reducer.ts @@ -2,127 +2,127 @@ import type { AgentEvent, StepId, StepMetrics, TurnMetrics, Usage } from "@dispa import type { BuildingStep, LiveTurn, MetricsState, TurnMetricsEntry } from "./types"; function sumStepUsages(steps: readonly BuildingStep[]): Usage { - let inputTokens = 0; - let outputTokens = 0; - let hasCacheRead = false; - let hasCacheWrite = false; - let cacheReadTokens = 0; - let cacheWriteTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let hasCacheRead = false; + let hasCacheWrite = false; + let cacheReadTokens = 0; + let cacheWriteTokens = 0; - for (const step of steps) { - if (step.usage === undefined) continue; - inputTokens += step.usage.inputTokens; - outputTokens += step.usage.outputTokens; - if (step.usage.cacheReadTokens !== undefined && step.usage.cacheReadTokens > 0) { - hasCacheRead = true; - cacheReadTokens += step.usage.cacheReadTokens; - } - if (step.usage.cacheWriteTokens !== undefined && step.usage.cacheWriteTokens > 0) { - hasCacheWrite = true; - cacheWriteTokens += step.usage.cacheWriteTokens; - } - } + for (const step of steps) { + if (step.usage === undefined) continue; + inputTokens += step.usage.inputTokens; + outputTokens += step.usage.outputTokens; + if (step.usage.cacheReadTokens !== undefined && step.usage.cacheReadTokens > 0) { + hasCacheRead = true; + cacheReadTokens += step.usage.cacheReadTokens; + } + if (step.usage.cacheWriteTokens !== undefined && step.usage.cacheWriteTokens > 0) { + hasCacheWrite = true; + cacheWriteTokens += step.usage.cacheWriteTokens; + } + } - const base: Usage = { inputTokens, outputTokens }; - if (hasCacheRead) { - (base as { cacheReadTokens?: number }).cacheReadTokens = cacheReadTokens; - } - if (hasCacheWrite) { - (base as { cacheWriteTokens?: number }).cacheWriteTokens = cacheWriteTokens; - } - return base; + const base: Usage = { inputTokens, outputTokens }; + if (hasCacheRead) { + (base as { cacheReadTokens?: number }).cacheReadTokens = cacheReadTokens; + } + if (hasCacheWrite) { + (base as { cacheWriteTokens?: number }).cacheWriteTokens = cacheWriteTokens; + } + return base; } function buildingStepToMetrics(bs: BuildingStep): StepMetrics { - const usage: Usage = bs.usage ?? { inputTokens: 0, outputTokens: 0 }; - const base: StepMetrics = { stepId: bs.stepId as StepId, usage }; - if (bs.ttftMs !== undefined) { - (base as { ttftMs?: number }).ttftMs = bs.ttftMs; - } - if (bs.decodeMs !== undefined) { - (base as { decodeMs?: number }).decodeMs = bs.decodeMs; - } - if (bs.genTotalMs !== undefined) { - (base as { genTotalMs?: number }).genTotalMs = bs.genTotalMs; - } - return base; + const usage: Usage = bs.usage ?? { inputTokens: 0, outputTokens: 0 }; + const base: StepMetrics = { stepId: bs.stepId as StepId, usage }; + if (bs.ttftMs !== undefined) { + (base as { ttftMs?: number }).ttftMs = bs.ttftMs; + } + if (bs.decodeMs !== undefined) { + (base as { decodeMs?: number }).decodeMs = bs.decodeMs; + } + if (bs.genTotalMs !== undefined) { + (base as { genTotalMs?: number }).genTotalMs = bs.genTotalMs; + } + return base; } function getStep(lt: LiveTurn, id: string): BuildingStep { - const step = lt.stepMap.get(id); - if (step === undefined) throw new Error(`Missing step ${id} in live turn`); - return step; + const step = lt.stepMap.get(id); + if (step === undefined) throw new Error(`Missing step ${id} in live turn`); + return step; } function liveTurnToMetrics(lt: LiveTurn): TurnMetrics { - const buildingSteps = lt.stepOrder.map((id) => getStep(lt, id)); - const steps = buildingSteps.map((bs) => buildingStepToMetrics(bs)); - const usage = lt.doneUsage ?? sumStepUsages(buildingSteps); - const base: TurnMetrics = { turnId: lt.turnId, usage, steps }; - if (lt.durationMs !== undefined) { - (base as { durationMs?: number }).durationMs = lt.durationMs; - } - if (lt.doneContextSize !== undefined) { - (base as { contextSize?: number }).contextSize = lt.doneContextSize; - } - return base; + const buildingSteps = lt.stepOrder.map((id) => getStep(lt, id)); + const steps = buildingSteps.map((bs) => buildingStepToMetrics(bs)); + const usage = lt.doneUsage ?? sumStepUsages(buildingSteps); + const base: TurnMetrics = { turnId: lt.turnId, usage, steps }; + if (lt.durationMs !== undefined) { + (base as { durationMs?: number }).durationMs = lt.durationMs; + } + if (lt.doneContextSize !== undefined) { + (base as { contextSize?: number }).contextSize = lt.doneContextSize; + } + return base; } function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] { - const existing = state.live.get(turnId); - if (existing !== undefined) return [state, existing]; + const existing = state.live.get(turnId); + if (existing !== undefined) return [state, existing]; - const newTurn: LiveTurn = { - turnId, - done: false, - durationMs: undefined, - doneUsage: undefined, - doneContextSize: undefined, - stepMap: new Map(), - stepOrder: [], - }; - const newLive = new Map(state.live); - newLive.set(turnId, newTurn); - return [{ ...state, live: newLive, liveOrder: [...state.liveOrder, turnId] }, newTurn]; + const newTurn: LiveTurn = { + turnId, + done: false, + durationMs: undefined, + doneUsage: undefined, + doneContextSize: undefined, + stepMap: new Map(), + stepOrder: [], + }; + const newLive = new Map(state.live); + newLive.set(turnId, newTurn); + return [{ ...state, live: newLive, liveOrder: [...state.liveOrder, turnId] }, newTurn]; } function upsertStep(lt: LiveTurn, stepId: string, update: Partial): LiveTurn { - const existing = lt.stepMap.get(stepId); - if (existing !== undefined) { - const merged: BuildingStep = { - stepId, - usage: update.usage ?? existing.usage, - ttftMs: update.ttftMs ?? existing.ttftMs, - decodeMs: update.decodeMs ?? existing.decodeMs, - genTotalMs: update.genTotalMs ?? existing.genTotalMs, - complete: update.complete ?? existing.complete, - }; - const newMap = new Map(lt.stepMap); - newMap.set(stepId, merged); - return { ...lt, stepMap: newMap }; - } + const existing = lt.stepMap.get(stepId); + if (existing !== undefined) { + const merged: BuildingStep = { + stepId, + usage: update.usage ?? existing.usage, + ttftMs: update.ttftMs ?? existing.ttftMs, + decodeMs: update.decodeMs ?? existing.decodeMs, + genTotalMs: update.genTotalMs ?? existing.genTotalMs, + complete: update.complete ?? existing.complete, + }; + const newMap = new Map(lt.stepMap); + newMap.set(stepId, merged); + return { ...lt, stepMap: newMap }; + } - const fresh: BuildingStep = { - stepId, - usage: update.usage, - ttftMs: update.ttftMs, - decodeMs: update.decodeMs, - genTotalMs: update.genTotalMs, - complete: update.complete ?? false, - }; - const newMap = new Map(lt.stepMap); - newMap.set(stepId, fresh); - return { ...lt, stepMap: newMap, stepOrder: [...lt.stepOrder, stepId] }; + const fresh: BuildingStep = { + stepId, + usage: update.usage, + ttftMs: update.ttftMs, + decodeMs: update.decodeMs, + genTotalMs: update.genTotalMs, + complete: update.complete ?? false, + }; + const newMap = new Map(lt.stepMap); + newMap.set(stepId, fresh); + return { ...lt, stepMap: newMap, stepOrder: [...lt.stepOrder, stepId] }; } /** The initial empty metrics state. */ export function initialMetricsState(): MetricsState { - return { - live: new Map(), - liveOrder: [], - durable: new Map(), - durableOrder: [], - }; + return { + live: new Map(), + liveOrder: [], + durable: new Map(), + durableOrder: [], + }; } /** @@ -135,46 +135,46 @@ export function initialMetricsState(): MetricsState { * - All other event types: return state unchanged. */ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): MetricsState { - switch (event.type) { - case "usage": { - if (event.stepId === undefined) return state; - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated = upsertStep(lt, event.stepId, { usage: event.usage }); - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + switch (event.type) { + case "usage": { + if (event.stepId === undefined) return state; + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated = upsertStep(lt, event.stepId, { usage: event.usage }); + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - case "step-complete": { - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated = upsertStep(lt, event.stepId, { - ttftMs: event.ttftMs, - decodeMs: event.decodeMs, - genTotalMs: event.genTotalMs, - complete: true, - }); - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + case "step-complete": { + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated = upsertStep(lt, event.stepId, { + ttftMs: event.ttftMs, + decodeMs: event.decodeMs, + genTotalMs: event.genTotalMs, + complete: true, + }); + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - case "done": { - const [s1, lt] = ensureLiveTurn(state, event.turnId); - const updated: LiveTurn = { - ...lt, - done: true, - durationMs: event.durationMs ?? lt.durationMs, - doneUsage: event.usage ?? lt.doneUsage, - doneContextSize: event.contextSize ?? lt.doneContextSize, - }; - const newLive = new Map(s1.live); - newLive.set(event.turnId, updated); - return { ...s1, live: newLive }; - } + case "done": { + const [s1, lt] = ensureLiveTurn(state, event.turnId); + const updated: LiveTurn = { + ...lt, + done: true, + durationMs: event.durationMs ?? lt.durationMs, + doneUsage: event.usage ?? lt.doneUsage, + doneContextSize: event.contextSize ?? lt.doneContextSize, + }; + const newLive = new Map(s1.live); + newLive.set(event.turnId, updated); + return { ...s1, live: newLive }; + } - default: - return state; - } + default: + return state; + } } /** @@ -182,22 +182,22 @@ export function foldMetricsEvent(state: MetricsState, event: AgentEvent): Metric * for any shared `turnId`. */ export function applyDurableMetrics( - state: MetricsState, - turns: readonly TurnMetrics[], + state: MetricsState, + turns: readonly TurnMetrics[], ): MetricsState { - const newDurable = new Map(state.durable); - const newDurableOrder = [...state.durableOrder]; - for (const turn of turns) { - if (!newDurable.has(turn.turnId)) { - newDurableOrder.push(turn.turnId); - } - newDurable.set(turn.turnId, turn); - } - return { - ...state, - durable: newDurable, - durableOrder: newDurableOrder, - }; + const newDurable = new Map(state.durable); + const newDurableOrder = [...state.durableOrder]; + for (const turn of turns) { + if (!newDurable.has(turn.turnId)) { + newDurableOrder.push(turn.turnId); + } + newDurable.set(turn.turnId, turn); + } + return { + ...state, + durable: newDurable, + durableOrder: newDurableOrder, + }; } /** @@ -210,37 +210,37 @@ export function applyDurableMetrics( * Live turns with no completed steps and not done are omitted. */ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetricsEntry[] { - const result: TurnMetricsEntry[] = []; - const seen = new Set(); + const result: TurnMetricsEntry[] = []; + const seen = new Set(); - for (const turnId of state.durableOrder) { - const tm = state.durable.get(turnId); - if (tm !== undefined) { - result.push({ turnId, steps: tm.steps, total: tm }); - seen.add(turnId); - } - } + for (const turnId of state.durableOrder) { + const tm = state.durable.get(turnId); + if (tm !== undefined) { + result.push({ turnId, steps: tm.steps, total: tm }); + seen.add(turnId); + } + } - for (const turnId of state.liveOrder) { - if (seen.has(turnId)) continue; - const lt = state.live.get(turnId); - if (lt === undefined) continue; + for (const turnId of state.liveOrder) { + if (seen.has(turnId)) continue; + const lt = state.live.get(turnId); + if (lt === undefined) continue; - const completeSteps = lt.stepOrder - .map((id) => lt.stepMap.get(id)) - .filter((s): s is BuildingStep => s?.complete === true) - .map((s) => buildingStepToMetrics(s)); + const completeSteps = lt.stepOrder + .map((id) => lt.stepMap.get(id)) + .filter((s): s is BuildingStep => s?.complete === true) + .map((s) => buildingStepToMetrics(s)); - if (completeSteps.length === 0 && !lt.done) continue; + if (completeSteps.length === 0 && !lt.done) continue; - result.push({ - turnId, - steps: completeSteps, - total: lt.done ? liveTurnToMetrics(lt) : null, - }); - } + result.push({ + turnId, + steps: completeSteps, + total: lt.done ? liveTurnToMetrics(lt) : null, + }); + } - return result; + return result; } /** @@ -254,10 +254,10 @@ export function selectOrderedTurnMetrics(state: MetricsState): readonly TurnMetr * live for a shared `turnId` (it is the persisted, authoritative value). */ export function selectCurrentContextSize(state: MetricsState): number | undefined { - const ordered = selectOrderedTurnMetrics(state); - for (let i = ordered.length - 1; i >= 0; i--) { - const total = ordered[i]?.total; - if (total?.contextSize !== undefined) return total.contextSize; - } - return undefined; + const ordered = selectOrderedTurnMetrics(state); + for (let i = ordered.length - 1; i >= 0; i--) { + const total = ordered[i]?.total; + if (total?.contextSize !== undefined) return total.contextSize; + } + return undefined; } diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts index 5b96e0f..84d1904 100644 --- a/src/core/metrics/types.ts +++ b/src/core/metrics/types.ts @@ -5,28 +5,28 @@ export type { StepMetrics, TurnMetrics }; /** A step being built from live events (may be incomplete). */ export interface BuildingStep { - readonly stepId: string; - readonly usage: Usage | undefined; - readonly ttftMs: number | undefined; - readonly decodeMs: number | undefined; - readonly genTotalMs: number | undefined; - readonly complete: boolean; + readonly stepId: string; + readonly usage: Usage | undefined; + readonly ttftMs: number | undefined; + readonly decodeMs: number | undefined; + readonly genTotalMs: number | undefined; + readonly complete: boolean; } /** A turn being built from live events (in-flight). */ export interface LiveTurn { - readonly turnId: string; - readonly done: boolean; - readonly durationMs: number | undefined; - readonly doneUsage: Usage | undefined; - /** - * Context size carried on the turn's `done` event (the turn's FINAL step - * `inputTokens + outputTokens` — current context occupancy). `undefined` when - * the provider reported no per-step usage; never coerced to `0`. - */ - readonly doneContextSize: number | undefined; - readonly stepMap: ReadonlyMap; - readonly stepOrder: readonly string[]; + readonly turnId: string; + readonly done: boolean; + readonly durationMs: number | undefined; + readonly doneUsage: Usage | undefined; + /** + * Context size carried on the turn's `done` event (the turn's FINAL step + * `inputTokens + outputTokens` — current context occupancy). `undefined` when + * the provider reported no per-step usage; never coerced to `0`. + */ + readonly doneContextSize: number | undefined; + readonly stepMap: ReadonlyMap; + readonly stepOrder: readonly string[]; } /** @@ -36,62 +36,62 @@ export interface LiveTurn { * - `durable`: sealed turns keyed by `turnId` in the order they arrived. */ export interface MetricsState { - readonly live: ReadonlyMap; - readonly liveOrder: readonly string[]; - readonly durable: ReadonlyMap; - readonly durableOrder: readonly string[]; + readonly live: ReadonlyMap; + readonly liveOrder: readonly string[]; + readonly durable: ReadonlyMap; + readonly durableOrder: readonly string[]; } /** Per-turn placement entry: completed steps so far + optional turn total. */ export interface TurnMetricsEntry { - readonly turnId: string; - readonly steps: readonly StepMetrics[]; - readonly total: TurnMetrics | null; + readonly turnId: string; + readonly steps: readonly StepMetrics[]; + readonly total: TurnMetrics | null; } /** A row in the interleaved transcript: a render group, per-step metrics, or turn metrics. */ export type MetricsRow = - | { readonly kind: "group"; readonly group: RenderGroup } - | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } - | { - readonly kind: "turn-metrics"; - readonly turn: TurnMetrics; - /** 1-based turn number (the entry's position in the metrics array + 1). */ - readonly turnNumber: number; - /** Cumulative usage across all finalized turns up to and including this one. */ - readonly cumulativeUsage: Usage; - /** - * Usage of the most recent EARLIER finalized turn, or `null` when this is the - * first finalized turn. The baseline for cross-turn retention (expected cache). - */ - readonly prevTurnUsage: Usage | null; - }; + | { readonly kind: "group"; readonly group: RenderGroup } + | { readonly kind: "step-metrics"; readonly step: StepMetrics; readonly index: number } + | { + readonly kind: "turn-metrics"; + readonly turn: TurnMetrics; + /** 1-based turn number (the entry's position in the metrics array + 1). */ + readonly turnNumber: number; + /** Cumulative usage across all finalized turns up to and including this one. */ + readonly cumulativeUsage: Usage; + /** + * Usage of the most recent EARLIER finalized turn, or `null` when this is the + * first finalized turn. The baseline for cross-turn retention (expected cache). + */ + readonly prevTurnUsage: Usage | null; + }; /** Formatted cache hit-rate view: percentage + colour severity + hit flag. */ export interface CacheRateView { - /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ - readonly pct: number; - /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ - readonly level: "success" | "warning" | "error"; - /** Whether any input tokens were served from cache. */ - readonly isHit: boolean; + /** Cache hit rate as a 0..100 integer percentage (`cacheReadTokens / inputTokens`). */ + readonly pct: number; + /** Colour severity for a badge (maps to DaisyUI `badge-{level}`). */ + readonly level: "success" | "warning" | "error"; + /** Whether any input tokens were served from cache. */ + readonly isHit: boolean; } /** Formatted per-step view for display. */ export interface StepMetricsView { - readonly label: string; - readonly tokensLabel: string; - readonly tps: string | null; - readonly ttft: string | null; - readonly decode: string | null; - readonly genTotal: string | null; + readonly label: string; + readonly tokensLabel: string; + readonly tps: string | null; + readonly ttft: string | null; + readonly decode: string | null; + readonly genTotal: string | null; } /** Formatted per-turn view for display. */ export interface TurnMetricsView { - readonly label: string; - readonly tokensLabel: string; - readonly breakdown: string; - readonly tps: string | null; - readonly duration: string | null; + readonly label: string; + readonly tokensLabel: string; + readonly breakdown: string; + readonly tps: string | null; + readonly duration: string | null; } diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts index e7fd161..2c1c290 100644 --- a/src/core/protocol/index.ts +++ b/src/core/protocol/index.ts @@ -1,9 +1,9 @@ export { - applyServerMessage, - getSurfaceSpec, - initialState, - invoke, - subscribe, - unsubscribe, + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, } from "./reducer"; export type { ProtocolResult, ProtocolState, Subscription } from "./types"; diff --git a/src/core/protocol/reducer.test.ts b/src/core/protocol/reducer.test.ts index c8e517a..d42dce7 100644 --- a/src/core/protocol/reducer.test.ts +++ b/src/core/protocol/reducer.test.ts @@ -1,245 +1,245 @@ import { describe, expect, it } from "vitest"; import { - applyServerMessage, - getSurfaceSpec, - initialState, - invoke, - subscribe, - unsubscribe, + applyServerMessage, + getSurfaceSpec, + initialState, + invoke, + subscribe, + unsubscribe, } from "./reducer"; const makeSpec = (id: string, title = id) => ({ - id, - region: "test", - title, - fields: [], + id, + region: "test", + title, + fields: [], }); describe("initialState", () => { - it("returns empty catalog, no subscriptions, no error", () => { - const s = initialState(); - expect(s.catalog).toEqual([]); - expect(s.subscriptions.size).toBe(0); - expect(s.lastError).toBeNull(); - }); + it("returns empty catalog, no subscriptions, no error", () => { + const s = initialState(); + expect(s.catalog).toEqual([]); + expect(s.subscriptions.size).toBe(0); + expect(s.lastError).toBeNull(); + }); }); describe("applyServerMessage — catalog", () => { - it("replaces the catalog", () => { - const s = initialState(); - const catalog = [ - { id: "a", region: "r", title: "A" }, - { id: "b", region: "r", title: "B" }, - ]; - const next = applyServerMessage(s, { type: "catalog", catalog }); - expect(next.catalog).toEqual(catalog); - }); + it("replaces the catalog", () => { + const s = initialState(); + const catalog = [ + { id: "a", region: "r", title: "A" }, + { id: "b", region: "r", title: "B" }, + ]; + const next = applyServerMessage(s, { type: "catalog", catalog }); + expect(next.catalog).toEqual(catalog); + }); }); describe("applyServerMessage — surface", () => { - it("sets the spec for a subscribed surface", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - const spec = makeSpec("s1", "Surface 1"); - const next = applyServerMessage(s, { type: "surface", spec }); - expect(getSurfaceSpec(next, "s1")).toEqual(spec); - }); - - it("ignores a surface message for a non-subscribed surface", () => { - const s = initialState(); - const spec = makeSpec("unknown"); - const next = applyServerMessage(s, { type: "surface", spec }); - expect(next.subscriptions.has("unknown")).toBe(false); - }); + it("sets the spec for a subscribed surface", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + const spec = makeSpec("s1", "Surface 1"); + const next = applyServerMessage(s, { type: "surface", spec }); + expect(getSurfaceSpec(next, "s1")).toEqual(spec); + }); + + it("ignores a surface message for a non-subscribed surface", () => { + const s = initialState(); + const spec = makeSpec("unknown"); + const next = applyServerMessage(s, { type: "surface", spec }); + expect(next.subscriptions.has("unknown")).toBe(false); + }); }); describe("applyServerMessage — update", () => { - it("replaces spec for a subscribed surface", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1", "V1") }); - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, - }); - expect(getSurfaceSpec(next, "s1")?.title).toBe("V2"); - }); - - it("ignores an update for a non-subscribed surface", () => { - const s = initialState(); - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "nope", spec: makeSpec("nope") }, - }); - expect(next.subscriptions.has("nope")).toBe(false); - }); + it("replaces spec for a subscribed surface", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1", "V1") }); + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "s1", spec: makeSpec("s1", "V2") }, + }); + expect(getSurfaceSpec(next, "s1")?.title).toBe("V2"); + }); + + it("ignores an update for a non-subscribed surface", () => { + const s = initialState(); + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "nope", spec: makeSpec("nope") }, + }); + expect(next.subscriptions.has("nope")).toBe(false); + }); }); describe("applyServerMessage — error", () => { - it("records the error without throwing", () => { - const s = initialState(); - const err = { type: "error" as const, surfaceId: "s1", message: "boom" }; - const next = applyServerMessage(s, err); - expect(next.lastError).toEqual(err); - }); - - it("records error without surfaceId", () => { - const s = initialState(); - const err = { type: "error" as const, message: "global boom" }; - const next = applyServerMessage(s, err); - expect(next.lastError).toEqual(err); - }); + it("records the error without throwing", () => { + const s = initialState(); + const err = { type: "error" as const, surfaceId: "s1", message: "boom" }; + const next = applyServerMessage(s, err); + expect(next.lastError).toEqual(err); + }); + + it("records error without surfaceId", () => { + const s = initialState(); + const err = { type: "error" as const, message: "global boom" }; + const next = applyServerMessage(s, err); + expect(next.lastError).toEqual(err); + }); }); describe("subscribe", () => { - it("emits exactly one subscribe message (global, no conversationId)", () => { - const s = initialState(); - const result = subscribe(s, "s1"); - expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); - expect(result.outgoing).toHaveLength(1); - }); - - it("adds the surface to subscriptions with null spec", () => { - const s = initialState(); - const result = subscribe(s, "s1"); - expect(result.state.subscriptions.get("s1")).toEqual({ - conversationId: undefined, - spec: null, - }); - expect(getSurfaceSpec(result.state, "s1")).toBeNull(); - }); - - it("is idempotent — second subscribe with the same scope is a no-op", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - const result = subscribe(s, "s1"); - expect(result.outgoing).toEqual([]); - expect(result.state).toBe(s); - }); + it("emits exactly one subscribe message (global, no conversationId)", () => { + const s = initialState(); + const result = subscribe(s, "s1"); + expect(result.outgoing).toEqual([{ type: "subscribe", surfaceId: "s1" }]); + expect(result.outgoing).toHaveLength(1); + }); + + it("adds the surface to subscriptions with null spec", () => { + const s = initialState(); + const result = subscribe(s, "s1"); + expect(result.state.subscriptions.get("s1")).toEqual({ + conversationId: undefined, + spec: null, + }); + expect(getSurfaceSpec(result.state, "s1")).toBeNull(); + }); + + it("is idempotent — second subscribe with the same scope is a no-op", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + const result = subscribe(s, "s1"); + expect(result.outgoing).toEqual([]); + expect(result.state).toBe(s); + }); }); describe("subscribe — conversation-scoped", () => { - it("includes conversationId in the subscribe message", () => { - const s = initialState(); - const result = subscribe(s, "cache-warming", "conv-A"); - expect(result.outgoing).toEqual([ - { type: "subscribe", surfaceId: "cache-warming", conversationId: "conv-A" }, - ]); - expect(result.state.subscriptions.get("cache-warming")?.conversationId).toBe("conv-A"); - }); - - it("re-scopes on conversation switch: unsubscribe old pair then subscribe new", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-A").state; - s = applyServerMessage(s, { - type: "surface", - spec: makeSpec("cw", "A-spec"), - conversationId: "conv-A", - }); - const result = subscribe(s, "cw", "conv-B"); - expect(result.outgoing).toEqual([ - { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, - { type: "subscribe", surfaceId: "cw", conversationId: "conv-B" }, - ]); - // previous spec retained until the new one arrives (no flicker) - expect(getSurfaceSpec(result.state, "cw")?.title).toBe("A-spec"); - expect(result.state.subscriptions.get("cw")?.conversationId).toBe("conv-B"); - }); - - it("drops a stale update echoing the previous conversationId", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-A").state; - s = subscribe(s, "cw", "conv-B").state; // re-scoped to B - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "cw", spec: makeSpec("cw", "STALE-A"), conversationId: "conv-A" }, - }); - expect(getSurfaceSpec(next, "cw")).toBeNull(); // stale ignored, no spec yet for B - }); - - it("accepts an update echoing the current conversationId", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-B").state; - const next = applyServerMessage(s, { - type: "update", - update: { surfaceId: "cw", spec: makeSpec("cw", "B-spec"), conversationId: "conv-B" }, - }); - expect(getSurfaceSpec(next, "cw")?.title).toBe("B-spec"); - }); - - it("accepts a global (no-echo) surface message even when subscribed with a conversationId", () => { - // loaded-extensions is global: server ignores our conversationId and echoes none. - let s = initialState(); - s = subscribe(s, "loaded-extensions", "conv-A").state; - const next = applyServerMessage(s, { - type: "surface", - spec: makeSpec("loaded-extensions", "Ext"), - }); - expect(getSurfaceSpec(next, "loaded-extensions")?.title).toBe("Ext"); - }); + it("includes conversationId in the subscribe message", () => { + const s = initialState(); + const result = subscribe(s, "cache-warming", "conv-A"); + expect(result.outgoing).toEqual([ + { type: "subscribe", surfaceId: "cache-warming", conversationId: "conv-A" }, + ]); + expect(result.state.subscriptions.get("cache-warming")?.conversationId).toBe("conv-A"); + }); + + it("re-scopes on conversation switch: unsubscribe old pair then subscribe new", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = applyServerMessage(s, { + type: "surface", + spec: makeSpec("cw", "A-spec"), + conversationId: "conv-A", + }); + const result = subscribe(s, "cw", "conv-B"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + { type: "subscribe", surfaceId: "cw", conversationId: "conv-B" }, + ]); + // previous spec retained until the new one arrives (no flicker) + expect(getSurfaceSpec(result.state, "cw")?.title).toBe("A-spec"); + expect(result.state.subscriptions.get("cw")?.conversationId).toBe("conv-B"); + }); + + it("drops a stale update echoing the previous conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + s = subscribe(s, "cw", "conv-B").state; // re-scoped to B + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "STALE-A"), conversationId: "conv-A" }, + }); + expect(getSurfaceSpec(next, "cw")).toBeNull(); // stale ignored, no spec yet for B + }); + + it("accepts an update echoing the current conversationId", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-B").state; + const next = applyServerMessage(s, { + type: "update", + update: { surfaceId: "cw", spec: makeSpec("cw", "B-spec"), conversationId: "conv-B" }, + }); + expect(getSurfaceSpec(next, "cw")?.title).toBe("B-spec"); + }); + + it("accepts a global (no-echo) surface message even when subscribed with a conversationId", () => { + // loaded-extensions is global: server ignores our conversationId and echoes none. + let s = initialState(); + s = subscribe(s, "loaded-extensions", "conv-A").state; + const next = applyServerMessage(s, { + type: "surface", + spec: makeSpec("loaded-extensions", "Ext"), + }); + expect(getSurfaceSpec(next, "loaded-extensions")?.title).toBe("Ext"); + }); }); describe("unsubscribe", () => { - it("emits unsubscribe and drops the spec", () => { - let s = initialState(); - s = subscribe(s, "s1").state; - s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1") }); - const result = unsubscribe(s, "s1"); - expect(result.outgoing).toEqual([{ type: "unsubscribe", surfaceId: "s1" }]); - expect(result.state.subscriptions.has("s1")).toBe(false); - }); - - it("includes conversationId for a scoped subscription", () => { - let s = initialState(); - s = subscribe(s, "cw", "conv-A").state; - const result = unsubscribe(s, "cw"); - expect(result.outgoing).toEqual([ - { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, - ]); - }); - - it("is a no-op if not subscribed", () => { - const s = initialState(); - const result = unsubscribe(s, "nope"); - expect(result.outgoing).toEqual([]); - expect(result.state).toBe(s); - }); + it("emits unsubscribe and drops the spec", () => { + let s = initialState(); + s = subscribe(s, "s1").state; + s = applyServerMessage(s, { type: "surface", spec: makeSpec("s1") }); + const result = unsubscribe(s, "s1"); + expect(result.outgoing).toEqual([{ type: "unsubscribe", surfaceId: "s1" }]); + expect(result.state.subscriptions.has("s1")).toBe(false); + }); + + it("includes conversationId for a scoped subscription", () => { + let s = initialState(); + s = subscribe(s, "cw", "conv-A").state; + const result = unsubscribe(s, "cw"); + expect(result.outgoing).toEqual([ + { type: "unsubscribe", surfaceId: "cw", conversationId: "conv-A" }, + ]); + }); + + it("is a no-op if not subscribed", () => { + const s = initialState(); + const result = unsubscribe(s, "nope"); + expect(result.outgoing).toEqual([]); + expect(result.state).toBe(s); + }); }); describe("invoke", () => { - it("emits the correct InvokeMessage", () => { - const s = initialState(); - const result = invoke(s, "s1", "toggle", true); - expect(result.outgoing).toEqual([ - { type: "invoke", surfaceId: "s1", actionId: "toggle", payload: true }, - ]); - }); - - it("omits payload when not provided", () => { - const s = initialState(); - const result = invoke(s, "s1", "click"); - expect(result.outgoing).toEqual([ - { type: "invoke", surfaceId: "s1", actionId: "click", payload: undefined }, - ]); - }); - - it("includes conversationId when provided", () => { - const s = initialState(); - const result = invoke(s, "cw", "cache-warming/set-interval", 120, "conv-A"); - expect(result.outgoing).toEqual([ - { - type: "invoke", - surfaceId: "cw", - actionId: "cache-warming/set-interval", - payload: 120, - conversationId: "conv-A", - }, - ]); - }); - - it("does not mutate state", () => { - const s = initialState(); - const result = invoke(s, "s1", "a1"); - expect(result.state).toBe(s); - }); + it("emits the correct InvokeMessage", () => { + const s = initialState(); + const result = invoke(s, "s1", "toggle", true); + expect(result.outgoing).toEqual([ + { type: "invoke", surfaceId: "s1", actionId: "toggle", payload: true }, + ]); + }); + + it("omits payload when not provided", () => { + const s = initialState(); + const result = invoke(s, "s1", "click"); + expect(result.outgoing).toEqual([ + { type: "invoke", surfaceId: "s1", actionId: "click", payload: undefined }, + ]); + }); + + it("includes conversationId when provided", () => { + const s = initialState(); + const result = invoke(s, "cw", "cache-warming/set-interval", 120, "conv-A"); + expect(result.outgoing).toEqual([ + { + type: "invoke", + surfaceId: "cw", + actionId: "cache-warming/set-interval", + payload: 120, + conversationId: "conv-A", + }, + ]); + }); + + it("does not mutate state", () => { + const s = initialState(); + const result = invoke(s, "s1", "a1"); + expect(result.state).toBe(s); + }); }); diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts index 3d6b1c8..976eb88 100644 --- a/src/core/protocol/reducer.ts +++ b/src/core/protocol/reducer.ts @@ -1,34 +1,34 @@ import type { - InvokeMessage, - SubscribeMessage, - SurfaceServerMessage, - SurfaceSpec, - UnsubscribeMessage, + InvokeMessage, + SubscribeMessage, + SurfaceServerMessage, + SurfaceSpec, + UnsubscribeMessage, } from "@dispatch/ui-contract"; import type { ProtocolResult, ProtocolState } from "./types"; /** The initial protocol state: empty catalog, no subscriptions, no error. */ export function initialState(): ProtocolState { - return { - catalog: [], - subscriptions: new Map(), - lastError: null, - }; + return { + catalog: [], + subscriptions: new Map(), + lastError: null, + }; } // ── Message builders (respect exactOptionalPropertyTypes: omit `conversationId` // entirely for a global subscription rather than setting it to `undefined`). ── function subMsg(surfaceId: string, conversationId: string | undefined): SubscribeMessage { - return conversationId === undefined - ? { type: "subscribe", surfaceId } - : { type: "subscribe", surfaceId, conversationId }; + return conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId }; } function unsubMsg(surfaceId: string, conversationId: string | undefined): UnsubscribeMessage { - return conversationId === undefined - ? { type: "unsubscribe", surfaceId } - : { type: "unsubscribe", surfaceId, conversationId }; + return conversationId === undefined + ? { type: "unsubscribe", surfaceId } + : { type: "unsubscribe", surfaceId, conversationId }; } /** @@ -38,37 +38,37 @@ function unsubMsg(surfaceId: string, conversationId: string | undefined): Unsubs * surface echoes nothing (`undefined`) and is always current. */ function isCurrent(desiredId: string | undefined, echoedId: string | undefined): boolean { - return echoedId === undefined || echoedId === desiredId; + return echoedId === undefined || echoedId === desiredId; } /** Fold an inbound server message into the next protocol state. */ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState { - switch (msg.type) { - case "catalog": - return { ...state, catalog: msg.catalog }; + switch (msg.type) { + case "catalog": + return { ...state, catalog: msg.catalog }; - case "surface": { - const sub = state.subscriptions.get(msg.spec.id); - if (sub === undefined) return state; - if (!isCurrent(sub.conversationId, msg.conversationId)) return state; - const subs = new Map(state.subscriptions); - subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); - return { ...state, subscriptions: subs }; - } + case "surface": { + const sub = state.subscriptions.get(msg.spec.id); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, msg.conversationId)) return state; + const subs = new Map(state.subscriptions); + subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); + return { ...state, subscriptions: subs }; + } - case "update": { - const { surfaceId, spec, conversationId } = msg.update; - const sub = state.subscriptions.get(surfaceId); - if (sub === undefined) return state; - if (!isCurrent(sub.conversationId, conversationId)) return state; - const subs = new Map(state.subscriptions); - subs.set(surfaceId, { conversationId: sub.conversationId, spec }); - return { ...state, subscriptions: subs }; - } + case "update": { + const { surfaceId, spec, conversationId } = msg.update; + const sub = state.subscriptions.get(surfaceId); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, conversationId)) return state; + const subs = new Map(state.subscriptions); + subs.set(surfaceId, { conversationId: sub.conversationId, spec }); + return { ...state, subscriptions: subs }; + } - case "error": - return { ...state, lastError: msg }; - } + case "error": + return { ...state, lastError: msg }; + } } /** @@ -82,23 +82,23 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa * one, retaining the previous spec until the new one arrives (no flicker). */ export function subscribe( - state: ProtocolState, - surfaceId: string, - conversationId?: string, + state: ProtocolState, + surfaceId: string, + conversationId?: string, ): ProtocolResult { - const existing = state.subscriptions.get(surfaceId); - if (existing !== undefined && existing.conversationId === conversationId) { - return { state, outgoing: [] }; - } - const subs = new Map(state.subscriptions); - const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; - const priorSpec: SurfaceSpec | null = existing?.spec ?? null; - if (existing !== undefined) { - outgoing.push(unsubMsg(surfaceId, existing.conversationId)); - } - subs.set(surfaceId, { conversationId, spec: priorSpec }); - outgoing.push(subMsg(surfaceId, conversationId)); - return { state: { ...state, subscriptions: subs }, outgoing }; + const existing = state.subscriptions.get(surfaceId); + if (existing !== undefined && existing.conversationId === conversationId) { + return { state, outgoing: [] }; + } + const subs = new Map(state.subscriptions); + const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; + const priorSpec: SurfaceSpec | null = existing?.spec ?? null; + if (existing !== undefined) { + outgoing.push(unsubMsg(surfaceId, existing.conversationId)); + } + subs.set(surfaceId, { conversationId, spec: priorSpec }); + outgoing.push(subMsg(surfaceId, conversationId)); + return { state: { ...state, subscriptions: subs }, outgoing }; } /** @@ -107,16 +107,16 @@ export function subscribe( * not subscribed. */ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - const existing = state.subscriptions.get(surfaceId); - if (existing === undefined) { - return { state, outgoing: [] }; - } - const subs = new Map(state.subscriptions); - subs.delete(surfaceId); - return { - state: { ...state, subscriptions: subs }, - outgoing: [unsubMsg(surfaceId, existing.conversationId)], - }; + const existing = state.subscriptions.get(surfaceId); + if (existing === undefined) { + return { state, outgoing: [] }; + } + const subs = new Map(state.subscriptions); + subs.delete(surfaceId); + return { + state: { ...state, subscriptions: subs }, + outgoing: [unsubMsg(surfaceId, existing.conversationId)], + }; } /** @@ -124,20 +124,20 @@ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolRe * `conversationId` for a scoped surface); no state change. */ export function invoke( - state: ProtocolState, - surfaceId: string, - actionId: string, - payload?: unknown, - conversationId?: string, + state: ProtocolState, + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, ): ProtocolResult { - const outgoing: InvokeMessage = - conversationId === undefined - ? { type: "invoke", surfaceId, actionId, payload } - : { type: "invoke", surfaceId, actionId, payload, conversationId }; - return { state, outgoing: [outgoing] }; + const outgoing: InvokeMessage = + conversationId === undefined + ? { type: "invoke", surfaceId, actionId, payload } + : { type: "invoke", surfaceId, actionId, payload, conversationId }; + return { state, outgoing: [outgoing] }; } /** The current spec for a subscribed surface, or `null` if absent/unsubscribed. */ export function getSurfaceSpec(state: ProtocolState, surfaceId: string): SurfaceSpec | null { - return state.subscriptions.get(surfaceId)?.spec ?? null; + return state.subscriptions.get(surfaceId)?.spec ?? null; } diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts index db8886a..6debb1d 100644 --- a/src/core/protocol/types.ts +++ b/src/core/protocol/types.ts @@ -1,8 +1,8 @@ import type { - SurfaceCatalog, - SurfaceClientMessage, - SurfaceErrorMessage, - SurfaceSpec, + SurfaceCatalog, + SurfaceClientMessage, + SurfaceErrorMessage, + SurfaceSpec, } from "@dispatch/ui-contract"; /** @@ -16,22 +16,22 @@ import type { * is always accepted. `spec` is `null` until the first `surface` arrives. */ export interface Subscription { - readonly conversationId: string | undefined; - readonly spec: SurfaceSpec | null; + readonly conversationId: string | undefined; + readonly spec: SurfaceSpec | null; } /** The client-side view of the surface protocol state. */ export interface ProtocolState { - /** The latest catalog received from the server (empty until first CatalogMessage). */ - readonly catalog: SurfaceCatalog; - /** Surfaces the client intends to be subscribed to, keyed by surfaceId. */ - readonly subscriptions: ReadonlyMap; - /** The last error received from the server, if any. */ - readonly lastError: SurfaceErrorMessage | null; + /** The latest catalog received from the server (empty until first CatalogMessage). */ + readonly catalog: SurfaceCatalog; + /** Surfaces the client intends to be subscribed to, keyed by surfaceId. */ + readonly subscriptions: ReadonlyMap; + /** The last error received from the server, if any. */ + readonly lastError: SurfaceErrorMessage | null; } /** A state transition result: the next state plus any outgoing messages to send. */ export interface ProtocolResult { - readonly state: ProtocolState; - readonly outgoing: readonly SurfaceClientMessage[]; + readonly state: ProtocolState; + readonly outgoing: readonly SurfaceClientMessage[]; } diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index b5dd8e1..880af07 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -2,241 +2,241 @@ import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/tra import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - assertAgentEventExhaustive, - assertChunkExhaustive, - assertWsClientMessageExhaustive, - assertWsServerMessageExhaustive, + assertAgentEventExhaustive, + assertChunkExhaustive, + assertWsClientMessageExhaustive, + assertWsServerMessageExhaustive, } from "./conformance"; describe("StoredChunk round-trips JSON", () => { - it("preserves shape through JSON serialize/deserialize", () => { - const original: StoredChunk = { - seq: 42, - role: "assistant", - chunk: { type: "text", text: "hello" }, - }; - const roundTripped: StoredChunk = JSON.parse(JSON.stringify(original)) as StoredChunk; - expect(roundTripped).toEqual(original); - expect(roundTripped.seq).toBe(42); - expect(roundTripped.role).toBe("assistant"); - expect(roundTripped.chunk.type).toBe("text"); - }); + it("preserves shape through JSON serialize/deserialize", () => { + const original: StoredChunk = { + seq: 42, + role: "assistant", + chunk: { type: "text", text: "hello" }, + }; + const roundTripped: StoredChunk = JSON.parse(JSON.stringify(original)) as StoredChunk; + expect(roundTripped).toEqual(original); + expect(roundTripped.seq).toBe(42); + expect(roundTripped.role).toBe("assistant"); + expect(roundTripped.chunk.type).toBe("text"); + }); }); describe("classifies every AgentEvent type", () => { - const samples: AgentEvent[] = [ - { type: "status", conversationId: "c1", status: "idle" }, - { type: "turn-start", conversationId: "c1", turnId: "t1" }, - { type: "user-message", conversationId: "c1", turnId: "t1", text: "hi" }, - { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }, - { type: "reasoning-delta", conversationId: "c1", turnId: "t1", delta: "thinking" }, - { - type: "tool-call", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - toolName: "read", - input: {}, - stepId: "t1#0" as StepId, - }, - { - type: "tool-result", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - toolName: "read", - content: "ok", - isError: false, - stepId: "t1#0" as StepId, - }, - { - type: "tool-output", - conversationId: "c1", - turnId: "t1", - toolCallId: "tc1", - data: "out", - stream: "stdout", - }, - { - type: "usage", - conversationId: "c1", - turnId: "t1", - usage: { inputTokens: 10, outputTokens: 20 }, - }, - { - type: "step-complete", - conversationId: "c1", - turnId: "t1", - stepId: "t1#0" as StepId, - ttftMs: 300, - decodeMs: 700, - genTotalMs: 1000, - }, - { type: "error", conversationId: "c1", turnId: "t1", message: "oops" }, - { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, - { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, - { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, - { - type: "provider-retry", - conversationId: "c1", - turnId: "t1", - attempt: 0, - delayMs: 5000, - message: "HTTP 429: overloaded", - code: "429", - }, - ]; + const samples: AgentEvent[] = [ + { type: "status", conversationId: "c1", status: "idle" }, + { type: "turn-start", conversationId: "c1", turnId: "t1" }, + { type: "user-message", conversationId: "c1", turnId: "t1", text: "hi" }, + { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }, + { type: "reasoning-delta", conversationId: "c1", turnId: "t1", delta: "thinking" }, + { + type: "tool-call", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + toolName: "read", + input: {}, + stepId: "t1#0" as StepId, + }, + { + type: "tool-result", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + toolName: "read", + content: "ok", + isError: false, + stepId: "t1#0" as StepId, + }, + { + type: "tool-output", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + data: "out", + stream: "stdout", + }, + { + type: "usage", + conversationId: "c1", + turnId: "t1", + usage: { inputTokens: 10, outputTokens: 20 }, + }, + { + type: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 300, + decodeMs: 700, + genTotalMs: 1000, + }, + { type: "error", conversationId: "c1", turnId: "t1", message: "oops" }, + { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, + { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, + { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, + { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt: 0, + delayMs: 5000, + message: "HTTP 429: overloaded", + code: "429", + }, + ]; - it("returns a stable label for every AgentEvent.type variant", () => { - const labels = samples.map(assertAgentEventExhaustive); - expect(labels).toEqual([ - "status", - "turn-start", - "user-message", - "text-delta", - "reasoning-delta", - "tool-call", - "tool-result", - "tool-output", - "usage", - "step-complete", - "error", - "done", - "turn-sealed", - "steering", - "provider-retry", - ]); - }); + it("returns a stable label for every AgentEvent.type variant", () => { + const labels = samples.map(assertAgentEventExhaustive); + expect(labels).toEqual([ + "status", + "turn-start", + "user-message", + "text-delta", + "reasoning-delta", + "tool-call", + "tool-result", + "tool-output", + "usage", + "step-complete", + "error", + "done", + "turn-sealed", + "steering", + "provider-retry", + ]); + }); - it("covers all 15 AgentEvent variants", () => { - expect(samples).toHaveLength(15); - }); + it("covers all 15 AgentEvent variants", () => { + expect(samples).toHaveLength(15); + }); }); describe("classifies every Chunk type", () => { - it("returns a stable label for each Chunk.type variant", () => { - const chunks = [ - { type: "text" as const, text: "a" }, - { type: "thinking" as const, text: "b" }, - { type: "tool-call" as const, toolCallId: "tc", toolName: "n", input: null }, - { - type: "tool-result" as const, - toolCallId: "tc", - toolName: "n", - content: "c", - isError: false, - }, - { type: "error" as const, message: "e" }, - { type: "system" as const, text: "s" }, - ]; - const labels = chunks.map(assertChunkExhaustive); - expect(labels).toEqual(["text", "thinking", "tool-call", "tool-result", "error", "system"]); - }); + it("returns a stable label for each Chunk.type variant", () => { + const chunks = [ + { type: "text" as const, text: "a" }, + { type: "thinking" as const, text: "b" }, + { type: "tool-call" as const, toolCallId: "tc", toolName: "n", input: null }, + { + type: "tool-result" as const, + toolCallId: "tc", + toolName: "n", + content: "c", + isError: false, + }, + { type: "error" as const, message: "e" }, + { type: "system" as const, text: "s" }, + ]; + const labels = chunks.map(assertChunkExhaustive); + expect(labels).toEqual(["text", "thinking", "tool-call", "tool-result", "error", "system"]); + }); }); describe("classifies every WsServerMessage type", () => { - it("returns a stable label for each variant", () => { - const msgs = [ - { type: "catalog" as const, catalog: [] }, - { type: "surface" as const, spec: { id: "s", region: "r", title: "S", fields: [] } }, - { - type: "update" as const, - update: { surfaceId: "s", spec: { id: "s", region: "r", title: "S", fields: [] } }, - }, - { type: "error" as const, message: "e" }, - { - type: "chat.delta" as const, - event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, - }, - { type: "chat.error" as const, message: "e" }, - { type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" }, - { - type: "conversation.statusChanged" as const, - conversationId: "c1", - status: "active" as const, - workspaceId: "w1", - }, - { - type: "conversation.compacted" as const, - conversationId: "c1", - newConversationId: "c2", - messagesSummarized: 10, - messagesKept: 5, - }, - ]; - const labels = msgs.map(assertWsServerMessageExhaustive); - expect(labels).toEqual([ - "catalog", - "surface", - "update", - "error", - "chat.delta", - "chat.error", - "conversation.open", - "conversation.statusChanged", - "conversation.compacted", - ]); - }); + it("returns a stable label for each variant", () => { + const msgs = [ + { type: "catalog" as const, catalog: [] }, + { type: "surface" as const, spec: { id: "s", region: "r", title: "S", fields: [] } }, + { + type: "update" as const, + update: { surfaceId: "s", spec: { id: "s", region: "r", title: "S", fields: [] } }, + }, + { type: "error" as const, message: "e" }, + { + type: "chat.delta" as const, + event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" }, + }, + { type: "chat.error" as const, message: "e" }, + { type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" }, + { + type: "conversation.statusChanged" as const, + conversationId: "c1", + status: "active" as const, + workspaceId: "w1", + }, + { + type: "conversation.compacted" as const, + conversationId: "c1", + newConversationId: "c2", + messagesSummarized: 10, + messagesKept: 5, + }, + ]; + const labels = msgs.map(assertWsServerMessageExhaustive); + expect(labels).toEqual([ + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + "conversation.statusChanged", + "conversation.compacted", + ]); + }); }); describe("classifies every WsClientMessage type", () => { - it("returns a stable label for each variant", () => { - const msgs = [ - { type: "subscribe" as const, surfaceId: "s" }, - { type: "unsubscribe" as const, surfaceId: "s" }, - { type: "invoke" as const, surfaceId: "s", actionId: "a" }, - { type: "chat.send" as const, message: "hi" }, - { type: "chat.subscribe" as const, conversationId: "c1" }, - { type: "chat.unsubscribe" as const, conversationId: "c1" }, - { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, - ]; - const labels = msgs.map(assertWsClientMessageExhaustive); - expect(labels).toEqual([ - "subscribe", - "unsubscribe", - "invoke", - "chat.send", - "chat.subscribe", - "chat.unsubscribe", - "chat.queue", - ]); - }); + it("returns a stable label for each variant", () => { + const msgs = [ + { type: "subscribe" as const, surfaceId: "s" }, + { type: "unsubscribe" as const, surfaceId: "s" }, + { type: "invoke" as const, surfaceId: "s", actionId: "a" }, + { type: "chat.send" as const, message: "hi" }, + { type: "chat.subscribe" as const, conversationId: "c1" }, + { type: "chat.unsubscribe" as const, conversationId: "c1" }, + { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, + ]; + const labels = msgs.map(assertWsClientMessageExhaustive); + expect(labels).toEqual([ + "subscribe", + "unsubscribe", + "invoke", + "chat.send", + "chat.subscribe", + "chat.unsubscribe", + "chat.queue", + ]); + }); }); describe("ChatSendMessage shape is constructible", () => { - it("constructs a minimal ChatSendMessage", () => { - const msg: ChatSendMessage = { type: "chat.send", message: "hello" }; - expect(msg.type).toBe("chat.send"); - expect(msg.message).toBe("hello"); - }); + it("constructs a minimal ChatSendMessage", () => { + const msg: ChatSendMessage = { type: "chat.send", message: "hello" }; + expect(msg.type).toBe("chat.send"); + expect(msg.message).toBe("hello"); + }); - it("constructs a full ChatSendMessage", () => { - const msg: ChatSendMessage = { - type: "chat.send", - conversationId: "c1", - message: "hello", - model: "default/gpt-4", - cwd: "/tmp", - }; - expect(msg.conversationId).toBe("c1"); - expect(msg.model).toBe("default/gpt-4"); - expect(msg.cwd).toBe("/tmp"); - }); + it("constructs a full ChatSendMessage", () => { + const msg: ChatSendMessage = { + type: "chat.send", + conversationId: "c1", + message: "hello", + model: "default/gpt-4", + cwd: "/tmp", + }; + expect(msg.conversationId).toBe("c1"); + expect(msg.model).toBe("default/gpt-4"); + expect(msg.cwd).toBe("/tmp"); + }); }); describe("ConversationHistoryResponse shape is constructible", () => { - it("constructs a response with chunks", () => { - const resp: ConversationHistoryResponse = { - chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }], - latestSeq: 1, - }; - expect(resp.chunks).toHaveLength(1); - expect(resp.latestSeq).toBe(1); - }); + it("constructs a response with chunks", () => { + const resp: ConversationHistoryResponse = { + chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }], + latestSeq: 1, + }; + expect(resp.chunks).toHaveLength(1); + expect(resp.latestSeq).toBe(1); + }); - it("constructs an empty (caught-up) response", () => { - const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 }; - expect(resp.chunks).toHaveLength(0); - expect(resp.latestSeq).toBe(5); - }); + it("constructs an empty (caught-up) response", () => { + const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 }; + expect(resp.chunks).toHaveLength(0); + expect(resp.latestSeq).toBe(5); + }); }); diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts index 7da4794..412d80a 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -7,62 +7,62 @@ import type { AgentEvent, Chunk } from "@dispatch/wire"; * default branch becomes reachable → TypeScript error at build time. */ export function assertAgentEventExhaustive(event: AgentEvent): string { - switch (event.type) { - case "status": - return "status"; - case "turn-start": - return "turn-start"; - case "user-message": - return "user-message"; - case "text-delta": - return "text-delta"; - case "reasoning-delta": - return "reasoning-delta"; - case "tool-call": - return "tool-call"; - case "tool-result": - return "tool-result"; - case "tool-output": - return "tool-output"; - case "usage": - return "usage"; - case "error": - return "error"; - case "done": - return "done"; - case "turn-sealed": - return "turn-sealed"; - case "step-complete": - return "step-complete"; - case "steering": - return "steering"; - case "provider-retry": - return "provider-retry"; - default: - return event satisfies never; - } + switch (event.type) { + case "status": + return "status"; + case "turn-start": + return "turn-start"; + case "user-message": + return "user-message"; + case "text-delta": + return "text-delta"; + case "reasoning-delta": + return "reasoning-delta"; + case "tool-call": + return "tool-call"; + case "tool-result": + return "tool-result"; + case "tool-output": + return "tool-output"; + case "usage": + return "usage"; + case "error": + return "error"; + case "done": + return "done"; + case "turn-sealed": + return "turn-sealed"; + case "step-complete": + return "step-complete"; + case "steering": + return "steering"; + case "provider-retry": + return "provider-retry"; + default: + return event satisfies never; + } } /** * Compile-time exhaustiveness guard for `Chunk.type`. */ export function assertChunkExhaustive(chunk: Chunk): string { - switch (chunk.type) { - case "text": - return "text"; - case "thinking": - return "thinking"; - case "tool-call": - return "tool-call"; - case "tool-result": - return "tool-result"; - case "error": - return "error"; - case "system": - return "system"; - default: - return chunk satisfies never; - } + switch (chunk.type) { + case "text": + return "text"; + case "thinking": + return "thinking"; + case "tool-call": + return "tool-call"; + case "tool-result": + return "tool-result"; + case "error": + return "error"; + case "system": + return "system"; + default: + return chunk satisfies never; + } } /** @@ -70,28 +70,28 @@ export function assertChunkExhaustive(chunk: Chunk): string { * Covers both surface ops and chat ops. */ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { - switch (msg.type) { - case "catalog": - return "catalog"; - case "surface": - return "surface"; - case "update": - return "update"; - case "error": - return "error"; - case "chat.delta": - return "chat.delta"; - case "chat.error": - return "chat.error"; - case "conversation.open": - return "conversation.open"; - case "conversation.statusChanged": - return "conversation.statusChanged"; - case "conversation.compacted": - return "conversation.compacted"; - default: - return msg satisfies never; - } + switch (msg.type) { + case "catalog": + return "catalog"; + case "surface": + return "surface"; + case "update": + return "update"; + case "error": + return "error"; + case "chat.delta": + return "chat.delta"; + case "chat.error": + return "chat.error"; + case "conversation.open": + return "conversation.open"; + case "conversation.statusChanged": + return "conversation.statusChanged"; + case "conversation.compacted": + return "conversation.compacted"; + default: + return msg satisfies never; + } } /** @@ -99,22 +99,22 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { * Covers both surface ops and chat ops. */ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string { - switch (msg.type) { - case "subscribe": - return "subscribe"; - case "unsubscribe": - return "unsubscribe"; - case "invoke": - return "invoke"; - case "chat.send": - return "chat.send"; - case "chat.subscribe": - return "chat.subscribe"; - case "chat.unsubscribe": - return "chat.unsubscribe"; - case "chat.queue": - return "chat.queue"; - default: - return msg satisfies never; - } + switch (msg.type) { + case "subscribe": + return "subscribe"; + case "unsubscribe": + return "unsubscribe"; + case "invoke": + return "invoke"; + case "chat.send": + return "chat.send"; + case "chat.subscribe": + return "chat.subscribe"; + case "chat.unsubscribe": + return "chat.unsubscribe"; + case "chat.queue": + return "chat.queue"; + default: + return msg satisfies never; + } } diff --git a/src/core/wire/index.ts b/src/core/wire/index.ts index ae6b3e6..d4215cf 100644 --- a/src/core/wire/index.ts +++ b/src/core/wire/index.ts @@ -1,6 +1,6 @@ export { - assertAgentEventExhaustive, - assertChunkExhaustive, - assertWsClientMessageExhaustive, - assertWsServerMessageExhaustive, + assertAgentEventExhaustive, + assertChunkExhaustive, + assertWsClientMessageExhaustive, + assertWsServerMessageExhaustive, } from "./conformance"; diff --git a/src/features/cache-warming/index.ts b/src/features/cache-warming/index.ts index c432de6..976844b 100644 --- a/src/features/cache-warming/index.ts +++ b/src/features/cache-warming/index.ts @@ -3,6 +3,6 @@ export { default as CacheWarmingView } from "./ui/CacheWarmingView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "cache-warming", - description: "Prompt-cache warming controls, history, and countdown", + name: "cache-warming", + description: "Prompt-cache warming controls, history, and countdown", } as const; diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts index d5ea901..39fec80 100644 --- a/src/features/cache-warming/logic/view-model.test.ts +++ b/src/features/cache-warming/logic/view-model.test.ts @@ -1,228 +1,228 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; import { - clampMinutes, - clampSeconds, - colorClass, - formatCountdown, - formatWarmLabel, - fromMinSec, - initialWarmingState, - observeWarm, - parseControls, - parsePct, - secondsUntilNext, - statusForPct, - toMinSec, + clampMinutes, + clampSeconds, + colorClass, + formatCountdown, + formatWarmLabel, + fromMinSec, + initialWarmingState, + observeWarm, + parseControls, + parsePct, + secondsUntilNext, + statusForPct, + toMinSec, } from "./view-model"; const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({ - id: "cache-warming", - region: "side", - title: "Cache Warming", - fields, + id: "cache-warming", + region: "side", + title: "Cache Warming", + fields, }); describe("parsePct", () => { - it("parses a percentage string", () => { - expect(parsePct("100%")).toBe(100); - expect(parsePct("93 %")).toBe(93); - expect(parsePct("0%")).toBe(0); - }); - it("returns null for a dash / non-numeric", () => { - expect(parsePct("—")).toBeNull(); - expect(parsePct("n/a")).toBeNull(); - }); + it("parses a percentage string", () => { + expect(parsePct("100%")).toBe(100); + expect(parsePct("93 %")).toBe(93); + expect(parsePct("0%")).toBe(0); + }); + it("returns null for a dash / non-numeric", () => { + expect(parsePct("—")).toBeNull(); + expect(parsePct("n/a")).toBeNull(); + }); }); describe("parseControls", () => { - it("returns empty defaults for a null spec", () => { - const c = parseControls(null); - expect(c).toEqual({ - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, - }); - }); - - it("extracts toggle / number / both stats / timer by kind", () => { - const c = parseControls( - spec([ - { - kind: "toggle", - label: "Enabled", - value: true, - action: { actionId: "cache-warming/toggle" }, - }, - { - kind: "number", - label: "Interval", - value: 240, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }, - { kind: "stat", label: "Last cache rate", value: "61%" }, - { kind: "stat", label: "Cache retention", value: "100%" }, - { - kind: "custom", - rendererId: "cache-warming-timer", - payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, - }, - ]), - ); - expect(c).toEqual({ - enabled: true, - toggleActionId: "cache-warming/toggle", - intervalSeconds: 240, - setIntervalActionId: "cache-warming/set-interval", - lastPct: 61, - retentionPct: 100, - nextWarmAt: 1_700_000_240_000, - lastWarmAt: 1_700_000_000_000, - }); - }); - - it("tells the retention stat apart from the rate stat by label", () => { - const c = parseControls( - spec([ - { kind: "stat", label: "Cache retention", value: "100%" }, - { kind: "stat", label: "Last cache rate", value: "61%" }, - ]), - ); - expect(c.retentionPct).toBe(100); - expect(c.lastPct).toBe(61); - }); - - it("treats a '—' stat as no pct", () => { - const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); - expect(c.lastPct).toBeNull(); - }); - - it("ignores an unknown custom renderer and a malformed timer payload", () => { - const c = parseControls( - spec([ - { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, - { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, - ]), - ); - expect(c.nextWarmAt).toBeNull(); - expect(c.lastWarmAt).toBeNull(); - }); + it("returns empty defaults for a null spec", () => { + const c = parseControls(null); + expect(c).toEqual({ + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, + }); + }); + + it("extracts toggle / number / both stats / timer by kind", () => { + const c = parseControls( + spec([ + { + kind: "toggle", + label: "Enabled", + value: true, + action: { actionId: "cache-warming/toggle" }, + }, + { + kind: "number", + label: "Interval", + value: 240, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + { kind: "stat", label: "Cache retention", value: "100%" }, + { + kind: "custom", + rendererId: "cache-warming-timer", + payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 }, + }, + ]), + ); + expect(c).toEqual({ + enabled: true, + toggleActionId: "cache-warming/toggle", + intervalSeconds: 240, + setIntervalActionId: "cache-warming/set-interval", + lastPct: 61, + retentionPct: 100, + nextWarmAt: 1_700_000_240_000, + lastWarmAt: 1_700_000_000_000, + }); + }); + + it("tells the retention stat apart from the rate stat by label", () => { + const c = parseControls( + spec([ + { kind: "stat", label: "Cache retention", value: "100%" }, + { kind: "stat", label: "Last cache rate", value: "61%" }, + ]), + ); + expect(c.retentionPct).toBe(100); + expect(c.lastPct).toBe(61); + }); + + it("treats a '—' stat as no pct", () => { + const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }])); + expect(c.lastPct).toBeNull(); + }); + + it("ignores an unknown custom renderer and a malformed timer payload", () => { + const c = parseControls( + spec([ + { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } }, + { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" }, + ]), + ); + expect(c.nextWarmAt).toBeNull(); + expect(c.lastWarmAt).toBeNull(); + }); }); describe("interval ↔ min/sec", () => { - it("clampSeconds caps at 0..59", () => { - expect(clampSeconds(75)).toBe(59); - expect(clampSeconds(-3)).toBe(0); - expect(clampSeconds(30)).toBe(30); - expect(clampSeconds(Number.NaN)).toBe(0); - }); - it("clampMinutes floors at 0", () => { - expect(clampMinutes(-1)).toBe(0); - expect(clampMinutes(4)).toBe(4); - }); - it("toMinSec splits total seconds", () => { - expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); - expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); - expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); - }); - it("fromMinSec combines (clamping seconds to 59)", () => { - expect(fromMinSec(4, 0)).toBe(240); - expect(fromMinSec(2, 5)).toBe(125); - expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 - }); + it("clampSeconds caps at 0..59", () => { + expect(clampSeconds(75)).toBe(59); + expect(clampSeconds(-3)).toBe(0); + expect(clampSeconds(30)).toBe(30); + expect(clampSeconds(Number.NaN)).toBe(0); + }); + it("clampMinutes floors at 0", () => { + expect(clampMinutes(-1)).toBe(0); + expect(clampMinutes(4)).toBe(4); + }); + it("toMinSec splits total seconds", () => { + expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 }); + expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 }); + expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 }); + }); + it("fromMinSec combines (clamping seconds to 59)", () => { + expect(fromMinSec(4, 0)).toBe(240); + expect(fromMinSec(2, 5)).toBe(125); + expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59 + }); }); describe("status + formatting", () => { - it("statusForPct buckets high/mid/low", () => { - expect(statusForPct(100)).toBe("success"); - expect(statusForPct(80)).toBe("success"); - expect(statusForPct(60)).toBe("warning"); - expect(statusForPct(40)).toBe("warning"); - expect(statusForPct(10)).toBe("error"); - }); - it("colorClass maps to literal DaisyUI classes", () => { - expect(colorClass("success")).toBe("text-success"); - expect(colorClass("warning")).toBe("text-warning"); - expect(colorClass("error")).toBe("text-error"); - }); - it("formatWarmLabel matches the manual-warm phrasing", () => { - expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); - expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); - }); - it("formatCountdown renders s and m:ss", () => { - expect(formatCountdown(9)).toBe("9s"); - expect(formatCountdown(59)).toBe("59s"); - expect(formatCountdown(60)).toBe("1:00"); - expect(formatCountdown(185)).toBe("3:05"); - expect(formatCountdown(-5)).toBe("0s"); - }); + it("statusForPct buckets high/mid/low", () => { + expect(statusForPct(100)).toBe("success"); + expect(statusForPct(80)).toBe("success"); + expect(statusForPct(60)).toBe("warning"); + expect(statusForPct(40)).toBe("warning"); + expect(statusForPct(10)).toBe("error"); + }); + it("colorClass maps to literal DaisyUI classes", () => { + expect(colorClass("success")).toBe("text-success"); + expect(colorClass("warning")).toBe("text-warning"); + expect(colorClass("error")).toBe("text-error"); + }); + it("formatWarmLabel matches the manual-warm phrasing", () => { + expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit"); + expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit"); + }); + it("formatCountdown renders s and m:ss", () => { + expect(formatCountdown(9)).toBe("9s"); + expect(formatCountdown(59)).toBe("59s"); + expect(formatCountdown(60)).toBe("1:00"); + expect(formatCountdown(185)).toBe("3:05"); + expect(formatCountdown(-5)).toBe("0s"); + }); }); describe("warming history reducer (observeWarm)", () => { - it("starts empty", () => { - const s = initialWarmingState(); - expect(s.history).toEqual([]); - expect(s.lastWarmAt).toBeNull(); - }); - - it("records a new entry on each new authoritative lastWarmAt", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); - s = observeWarm(s, 2000, 90); - expect(s.history).toEqual([ - { pct: 90, at: 2000 }, - { pct: 100, at: 1000 }, - ]); - expect(s.lastWarmAt).toBe(2000); - }); - - it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); // warm - s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip - expect(s.history).toHaveLength(1); - }); - - it("records two warms with the SAME pct (distinct timestamps both count)", () => { - let s = initialWarmingState(); - s = observeWarm(s, 1000, 100); - s = observeWarm(s, 2000, 100); - expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); - }); - - it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { - let s = initialWarmingState(); - s = observeWarm(s, null, 100); - expect(s.history).toEqual([]); - s = observeWarm(s, 1000, null); - expect(s.history).toEqual([]); - expect(s.lastWarmAt).toBe(1000); - }); + it("starts empty", () => { + const s = initialWarmingState(); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBeNull(); + }); + + it("records a new entry on each new authoritative lastWarmAt", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 90); + expect(s.history).toEqual([ + { pct: 90, at: 2000 }, + { pct: 100, at: 1000 }, + ]); + expect(s.lastWarmAt).toBe(2000); + }); + + it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); // warm + s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip + expect(s.history).toHaveLength(1); + }); + + it("records two warms with the SAME pct (distinct timestamps both count)", () => { + let s = initialWarmingState(); + s = observeWarm(s, 1000, 100); + s = observeWarm(s, 2000, 100); + expect(s.history.map((e) => e.at)).toEqual([2000, 1000]); + }); + + it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => { + let s = initialWarmingState(); + s = observeWarm(s, null, 100); + expect(s.history).toEqual([]); + s = observeWarm(s, 1000, null); + expect(s.history).toEqual([]); + expect(s.lastWarmAt).toBe(1000); + }); }); describe("secondsUntilNext (authoritative, from nextWarmAt)", () => { - it("is null when nothing is scheduled (nextWarmAt null)", () => { - expect(secondsUntilNext(null, 5000)).toBeNull(); - }); - - it("counts down to nextWarmAt, floored at 0", () => { - expect(secondsUntilNext(10_000, 10_000)).toBe(0); - expect(secondsUntilNext(250_000, 10_000)).toBe(240); - expect(secondsUntilNext(70_000, 10_000)).toBe(60); - }); - - it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { - // Within the 3s grace an on-time warm may briefly read "0s"… - expect(secondsUntilNext(10_000, 11_000)).toBe(0); - expect(secondsUntilNext(10_000, 13_000)).toBe(0); - // …but beyond it the value is stale → null (the "waiting…" state). - expect(secondsUntilNext(10_000, 13_001)).toBeNull(); - expect(secondsUntilNext(5_000, 999_999)).toBeNull(); - }); + it("is null when nothing is scheduled (nextWarmAt null)", () => { + expect(secondsUntilNext(null, 5000)).toBeNull(); + }); + + it("counts down to nextWarmAt, floored at 0", () => { + expect(secondsUntilNext(10_000, 10_000)).toBe(0); + expect(secondsUntilNext(250_000, 10_000)).toBe(240); + expect(secondsUntilNext(70_000, 10_000)).toBe(60); + }); + + it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => { + // Within the 3s grace an on-time warm may briefly read "0s"… + expect(secondsUntilNext(10_000, 11_000)).toBe(0); + expect(secondsUntilNext(10_000, 13_000)).toBe(0); + // …but beyond it the value is stale → null (the "waiting…" state). + expect(secondsUntilNext(10_000, 13_001)).toBeNull(); + expect(secondsUntilNext(5_000, 999_999)).toBeNull(); + }); }); diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts index eb105f6..bc8cf2e 100644 --- a/src/features/cache-warming/logic/view-model.ts +++ b/src/features/cache-warming/logic/view-model.ts @@ -15,37 +15,37 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; // ── Manual-warm port (consumer-defines-port; the composition root adapts the // store's `POST /chat/warm` result to this shape). ────────────────────────── export type WarmFeedback = - | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } + | { readonly ok: false; readonly error: string }; export type WarmNow = () => Promise; // ── Parsed surface controls ─────────────────────────────────────────────────── export interface ParsedControls { - readonly enabled: boolean; - readonly toggleActionId: string | null; - readonly intervalSeconds: number; - readonly setIntervalActionId: string | null; - /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ - readonly lastPct: number | null; - /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ - readonly retentionPct: number | null; - /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ - readonly nextWarmAt: number | null; - /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ - readonly lastWarmAt: number | null; + readonly enabled: boolean; + readonly toggleActionId: string | null; + readonly intervalSeconds: number; + readonly setIntervalActionId: string | null; + /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ + readonly lastPct: number | null; + /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ + readonly retentionPct: number | null; + /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ + readonly nextWarmAt: number | null; + /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ + readonly lastWarmAt: number | null; } const EMPTY_CONTROLS: ParsedControls = { - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, }; /** The `cache-warming-timer` custom field's renderer id (this feature owns it). */ @@ -53,24 +53,24 @@ const TIMER_RENDERER_ID = "cache-warming-timer"; /** Parse a stat's display string (e.g. "100%", "93 %", "—") into a number or null. */ export function parsePct(value: string): number | null { - const match = value.match(/-?\d+(?:\.\d+)?/); - if (match === null) return null; - const n = Number(match[0]); - return Number.isFinite(n) ? n : null; + const match = value.match(/-?\d+(?:\.\d+)?/); + if (match === null) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; } /** A finite number, else null. */ function numOrNull(v: unknown): number | null { - return typeof v === "number" && Number.isFinite(v) ? v : null; + return typeof v === "number" && Number.isFinite(v) ? v : null; } /** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } { - if (typeof payload !== "object" || payload === null) { - return { nextWarmAt: null, lastWarmAt: null }; - } - const p = payload as Record; - return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; + if (typeof payload !== "object" || payload === null) { + return { nextWarmAt: null, lastWarmAt: null }; + } + const p = payload as Record; + return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; } /** @@ -80,79 +80,79 @@ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: * absent. */ export function parseControls(spec: SurfaceSpec | null): ParsedControls { - if (spec === null) return EMPTY_CONTROLS; - let enabled = false; - let toggleActionId: string | null = null; - let intervalSeconds = 0; - let setIntervalActionId: string | null = null; - let lastPct: number | null = null; - let retentionPct: number | null = null; - let nextWarmAt: number | null = null; - let lastWarmAt: number | null = null; - let seenToggle = false; - let seenNumber = false; - let seenRateStat = false; - for (const field of spec.fields) { - if (field.kind === "toggle" && !seenToggle) { - enabled = field.value; - toggleActionId = field.action.actionId; - seenToggle = true; - } else if (field.kind === "number" && !seenNumber) { - intervalSeconds = field.value; - setIntervalActionId = field.action.actionId; - seenNumber = true; - } else if (field.kind === "stat") { - // Retention is told apart by its label; everything else is the cache rate - // (first one wins, so a stray later stat can't clobber it). - if (/retention/i.test(field.label)) { - retentionPct = parsePct(field.value); - } else if (!seenRateStat) { - lastPct = parsePct(field.value); - seenRateStat = true; - } - } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { - const timer = parseTimer(field.payload); - nextWarmAt = timer.nextWarmAt; - lastWarmAt = timer.lastWarmAt; - } - } - return { - enabled, - toggleActionId, - intervalSeconds, - setIntervalActionId, - lastPct, - retentionPct, - nextWarmAt, - lastWarmAt, - }; + if (spec === null) return EMPTY_CONTROLS; + let enabled = false; + let toggleActionId: string | null = null; + let intervalSeconds = 0; + let setIntervalActionId: string | null = null; + let lastPct: number | null = null; + let retentionPct: number | null = null; + let nextWarmAt: number | null = null; + let lastWarmAt: number | null = null; + let seenToggle = false; + let seenNumber = false; + let seenRateStat = false; + for (const field of spec.fields) { + if (field.kind === "toggle" && !seenToggle) { + enabled = field.value; + toggleActionId = field.action.actionId; + seenToggle = true; + } else if (field.kind === "number" && !seenNumber) { + intervalSeconds = field.value; + setIntervalActionId = field.action.actionId; + seenNumber = true; + } else if (field.kind === "stat") { + // Retention is told apart by its label; everything else is the cache rate + // (first one wins, so a stray later stat can't clobber it). + if (/retention/i.test(field.label)) { + retentionPct = parsePct(field.value); + } else if (!seenRateStat) { + lastPct = parsePct(field.value); + seenRateStat = true; + } + } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { + const timer = parseTimer(field.payload); + nextWarmAt = timer.nextWarmAt; + lastWarmAt = timer.lastWarmAt; + } + } + return { + enabled, + toggleActionId, + intervalSeconds, + setIntervalActionId, + lastPct, + retentionPct, + nextWarmAt, + lastWarmAt, + }; } // ── Interval ↔ minutes/seconds (seconds capped at 59) ───────────────────────── export interface MinSec { - readonly minutes: number; - readonly seconds: number; + readonly minutes: number; + readonly seconds: number; } export function clampSeconds(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.min(59, Math.max(0, Math.floor(n))); + if (!Number.isFinite(n)) return 0; + return Math.min(59, Math.max(0, Math.floor(n))); } export function clampMinutes(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.max(0, Math.floor(n)); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.floor(n)); } export function toMinSec(totalSeconds: number): MinSec { - const total = Math.max(0, Math.floor(totalSeconds)); - return { minutes: Math.floor(total / 60), seconds: total % 60 }; + const total = Math.max(0, Math.floor(totalSeconds)); + return { minutes: Math.floor(total / 60), seconds: total % 60 }; } /** Combine a minutes + seconds pair (each clamped) into total seconds. */ export function fromMinSec(minutes: number, seconds: number): number { - return clampMinutes(minutes) * 60 + clampSeconds(seconds); + return clampMinutes(minutes) * 60 + clampSeconds(seconds); } // ── Status + formatting ─────────────────────────────────────────────────────── @@ -161,56 +161,56 @@ export type WarmStatus = "success" | "warning" | "error"; /** Cache-hit % → semantic status (green high, yellow mid, red low). */ export function statusForPct(pct: number): WarmStatus { - if (pct >= 80) return "success"; - if (pct >= 40) return "warning"; - return "error"; + if (pct >= 80) return "success"; + if (pct >= 40) return "warning"; + return "error"; } /** A status → its DaisyUI text-colour class (full literal so Tailwind keeps it). */ export function colorClass(status: WarmStatus): string { - switch (status) { - case "success": - return "text-success"; - case "warning": - return "text-warning"; - case "error": - return "text-error"; - } + switch (status) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-error"; + } } /** The status line for a warm, matching the manual-warm feedback phrasing. */ export function formatWarmLabel(pct: number): string { - return `Warmed — ${Math.round(pct)}% cache hit`; + return `Warmed — ${Math.round(pct)}% cache hit`; } /** Seconds → a short countdown string (e.g. "3:05", "9s"). */ export function formatCountdown(seconds: number): string { - const s = Math.max(0, Math.floor(seconds)); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rem = s % 60; - return `${m}:${String(rem).padStart(2, "0")}`; + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, "0")}`; } // ── Warming history reducer (keyed off the authoritative `lastWarmAt`) ───────── export interface WarmEntry { - readonly pct: number; - /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ - readonly at: number; + readonly pct: number; + /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ + readonly at: number; } export interface WarmingViewState { - /** Warmings, MOST RECENT FIRST. */ - readonly history: readonly WarmEntry[]; - /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ - readonly lastWarmAt: number | null; + /** Warmings, MOST RECENT FIRST. */ + readonly history: readonly WarmEntry[]; + /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ + readonly lastWarmAt: number | null; } const MAX_HISTORY = 50; export function initialWarmingState(): WarmingViewState { - return { history: [], lastWarmAt: null }; + return { history: [], lastWarmAt: null }; } /** @@ -221,14 +221,14 @@ export function initialWarmingState(): WarmingViewState { * ignored; a null pct advances the de-dup key without adding an entry. */ export function observeWarm( - state: WarmingViewState, - lastWarmAt: number | null, - pct: number | null, + state: WarmingViewState, + lastWarmAt: number | null, + pct: number | null, ): WarmingViewState { - if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; - if (pct === null) return { ...state, lastWarmAt }; - const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); - return { history, lastWarmAt }; + if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; + if (pct === null) return { ...state, lastWarmAt }; + const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); + return { history, lastWarmAt }; } /** @@ -248,7 +248,7 @@ const STALE_NEXT_WARM_MS = 3000; * when `nextWarmAt` is stale (further than the grace into the past). */ export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { - if (nextWarmAt === null) return null; - if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; - return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); + if (nextWarmAt === null) return null; + if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; + return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); } diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte index ced5e99..9b3d694 100644 --- a/src/features/cache-warming/ui/CacheWarmingView.svelte +++ b/src/features/cache-warming/ui/CacheWarmingView.svelte @@ -1,234 +1,244 @@
- - - - -
- Refresh interval - - - m - - s - -
- - - {#if !controls.enabled} -

Warming paused.

- {:else if remaining !== null} -

Next warm in {formatCountdown(remaining)}

- {:else} -

Next warm: waiting…

- {/if} - - - {#if controls.retentionPct !== null} -

- Cache retention: {controls.retentionPct}% -

- {/if} - - - - - {#if !canWarm} -

Open or start a conversation to control its cache warming.

- {:else if errorText} -

{errorText}

- {:else if manualResult} - -

- Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached) -

- {/if} - - - {#if latest} -
- -
- {formatWarmLabel(latest.pct)} -
-
- {#if earlier.length > 0} - {#each earlier as entry, i (i)} -

{formatWarmLabel(entry.pct)}

- {/each} - {:else} -

No earlier warmings.

- {/if} -
-
- {:else} -

No warming yet.

- {/if} + + + + +
+ Refresh interval + + + m + + s + +
+ + + {#if !controls.enabled} +

Warming paused.

+ {:else if remaining !== null} +

Next warm in {formatCountdown(remaining)}

+ {:else} +

Next warm: waiting…

+ {/if} + + + {#if controls.retentionPct !== null} +

+ Cache retention: {controls.retentionPct}% +

+ {/if} + + + + + {#if !canWarm} +

Open or start a conversation to control its cache warming.

+ {:else if errorText} +

{errorText}

+ {:else if manualResult} + +

+ Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt + cached) +

+ {/if} + + + {#if latest} +
+ +
+ {formatWarmLabel(latest.pct)} +
+
+ {#if earlier.length > 0} + {#each earlier as entry, i (i)} +

{formatWarmLabel(entry.pct)}

+ {/each} + {:else} +

No earlier warmings.

+ {/if} +
+
+ {:else} +

No warming yet.

+ {/if}
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index c120916..ddb094d 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -1,23 +1,23 @@ export type { - ProviderRetryView, - RenderedChunk, - RenderGroup, - ToolBatchEntry, + ProviderRetryView, + RenderedChunk, + RenderGroup, + ToolBatchEntry, } from "../../core/chunks"; export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { - EffortOption, - ReasoningEffortSaveResult, - SaveReasoningEffort, + EffortOption, + ReasoningEffortSaveResult, + SaveReasoningEffort, } from "./reasoning-effort"; export { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; @@ -30,6 +30,6 @@ export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "chat", - description: "Conversation turns, composer, model selector, and metrics", + name: "chat", + description: "Conversation turns, composer, model selector, and metrics", } as const; diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 109cae1..deb673d 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest"; import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select"; describe("splitModelName", () => { - it("splits on the first slash", () => { - expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); - }); - - it("keeps slashes in the model part (splits only the first)", () => { - expect(splitModelName("openrouter/anthropic/claude")).toEqual({ - key: "openrouter", - model: "anthropic/claude", - }); - }); - - it("treats a slashless name as all key", () => { - expect(splitModelName("local")).toEqual({ key: "local", model: "" }); - }); + it("splits on the first slash", () => { + expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); + }); + + it("keeps slashes in the model part (splits only the first)", () => { + expect(splitModelName("openrouter/anthropic/claude")).toEqual({ + key: "openrouter", + model: "anthropic/claude", + }); + }); + + it("treats a slashless name as all key", () => { + expect(splitModelName("local")).toEqual({ key: "local", model: "" }); + }); }); describe("joinModelName", () => { - it("recombines key + model", () => { - expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); - }); - - it("returns just the key when the model is empty", () => { - expect(joinModelName("local", "")).toBe("local"); - }); - - it("round-trips with splitModelName", () => { - const full = "openrouter/anthropic/claude"; - const { key, model } = splitModelName(full); - expect(joinModelName(key, model)).toBe(full); - }); + it("recombines key + model", () => { + expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); + }); + + it("returns just the key when the model is empty", () => { + expect(joinModelName("local", "")).toBe("local"); + }); + + it("round-trips with splitModelName", () => { + const full = "openrouter/anthropic/claude"; + const { key, model } = splitModelName(full); + expect(joinModelName(key, model)).toBe(full); + }); }); describe("modelKeys", () => { - it("returns distinct keys in first-seen order", () => { - expect( - modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), - ).toEqual(["openai", "anthropic", "google"]); - }); - - it("is empty for no models", () => { - expect(modelKeys([])).toEqual([]); - }); + it("returns distinct keys in first-seen order", () => { + expect( + modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), + ).toEqual(["openai", "anthropic", "google"]); + }); + + it("is empty for no models", () => { + expect(modelKeys([])).toEqual([]); + }); }); describe("modelsForKey", () => { - it("returns the model suffixes under a key, in order", () => { - const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; - expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); - }); - - it("returns empty for an unknown key", () => { - expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); - }); + it("returns the model suffixes under a key, in order", () => { + const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; + expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); + }); + + it("returns empty for an unknown key", () => { + expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); + }); }); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index b1d70b9..db41772 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -8,42 +8,42 @@ */ export interface SplitModel { - readonly key: string; - readonly model: string; + readonly key: string; + readonly model: string; } /** Split `/` on the first slash. A slashless name is all-key. */ export function splitModelName(full: string): SplitModel { - const i = full.indexOf("/"); - if (i === -1) return { key: full, model: "" }; - return { key: full.slice(0, i), model: full.slice(i + 1) }; + const i = full.indexOf("/"); + if (i === -1) return { key: full, model: "" }; + return { key: full.slice(0, i), model: full.slice(i + 1) }; } /** Recombine a key + model into a `/` name (key-only if no model). */ export function joinModelName(key: string, model: string): string { - return model === "" ? key : `${key}/${model}`; + return model === "" ? key : `${key}/${model}`; } /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const full of models) { - const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } - } - return out; + const seen = new Set(); + const out: string[] = []; + for (const full of models) { + const { key } = splitModelName(full); + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; } /** The model suffixes available under a given key, in order. */ export function modelsForKey(models: readonly string[], key: string): string[] { - const out: string[] = []; - for (const full of models) { - const split = splitModelName(full); - if (split.key === key) out.push(split.model); - } - return out; + const out: string[] = []; + for (const full of models) { + const split = splitModelName(full); + if (split.key === key) out.push(split.model); + } + return out; } diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts index ffe2c94..2fe10dc 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,8 +1,8 @@ import type { - ChatQueueMessage, - ChatSendMessage, - ConversationHistoryResponse, - ConversationMetricsResponse, + ChatQueueMessage, + ChatSendMessage, + ConversationHistoryResponse, + ConversationMetricsResponse, } from "@dispatch/transport-contract"; /** @@ -11,7 +11,7 @@ import type { * auto-starts a turn if idle). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage): void; } /** @@ -19,10 +19,10 @@ export interface ChatTransport { * Both must be POSITIVE integers when present (the server 400s otherwise). */ export interface HistoryWindow { - /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ - readonly limit?: number; - /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ - readonly beforeSeq?: number; + /** Return only the NEWEST `limit` chunks of the selection (still ascending). */ + readonly limit?: number; + /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */ + readonly beforeSeq?: number; } /** @@ -34,9 +34,9 @@ export interface HistoryWindow { * satisfies this naturally). */ export type HistorySync = ( - conversationId: string, - sinceSeq: number, - window?: HistoryWindow, + conversationId: string, + sinceSeq: number, + window?: HistoryWindow, ) => Promise; /** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */ diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 8f76dea..6d409e9 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -1,45 +1,45 @@ import { describe, expect, it } from "vitest"; import { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, - isReasoningEffort, - REASONING_EFFORT_LEVELS, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, } from "./reasoning-effort"; describe("reasoning-effort helpers", () => { - it("ladder matches the wire contract, in ascending depth order", () => { - expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); - }); + it("ladder matches the wire contract, in ascending depth order", () => { + expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); - it("the server default is high", () => { - expect(DEFAULT_REASONING_EFFORT).toBe("high"); - }); + it("the server default is high", () => { + expect(DEFAULT_REASONING_EFFORT).toBe("high"); + }); - it("isReasoningEffort narrows ladder strings and rejects everything else", () => { - for (const level of REASONING_EFFORT_LEVELS) { - expect(isReasoningEffort(level)).toBe(true); - } - expect(isReasoningEffort("banana")).toBe(false); - expect(isReasoningEffort("")).toBe(false); - expect(isReasoningEffort("HIGH")).toBe(false); - }); + it("isReasoningEffort narrows ladder strings and rejects everything else", () => { + for (const level of REASONING_EFFORT_LEVELS) { + expect(isReasoningEffort(level)).toBe(true); + } + expect(isReasoningEffort("banana")).toBe(false); + expect(isReasoningEffort("")).toBe(false); + expect(isReasoningEffort("HIGH")).toBe(false); + }); - it("effectiveEffort maps null (never set) to the default, not 'off'", () => { - expect(effectiveEffort(null)).toBe("high"); - }); + it("effectiveEffort maps null (never set) to the default, not 'off'", () => { + expect(effectiveEffort(null)).toBe("high"); + }); - it("effectiveEffort passes a persisted value through", () => { - expect(effectiveEffort("xhigh")).toBe("xhigh"); - expect(effectiveEffort("low")).toBe("low"); - }); + it("effectiveEffort passes a persisted value through", () => { + expect(effectiveEffort("xhigh")).toBe("xhigh"); + expect(effectiveEffort("low")).toBe("low"); + }); - it("effortOptions lists every level once and marks only the default", () => { - const options = effortOptions(); - expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); - expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); - for (const option of options) { - if (option.value !== "high") expect(option.label).toBe(option.value); - } - }); + it("effortOptions lists every level once and marks only the default", () => { + const options = effortOptions(); + expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + for (const option of options) { + if (option.value !== "high") expect(option.label).toBe(option.value); + } + }); }); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 2a55089..1eb77b6 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -13,11 +13,11 @@ import type { ReasoningEffort } from "@dispatch/transport-contract"; /** The canonical ladder, in ascending thinking-depth order (`wire@0.7.0`). */ export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", - "max", + "low", + "medium", + "high", + "xhigh", + "max", ]; /** The server's fallback when nothing is set (the resolution chain's tail). */ @@ -25,7 +25,7 @@ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; /** Narrow an untrusted string (e.g. a ` -
- {rendered.streaming ? "Thinking" : "Thoughts"} - {#if rendered.streaming} - - {/if} -
-
-

{rendered.chunk.text}

-
- - - - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - -
-
- {#if rendered.chunk.type === "tool-call"} -
- -
- - {rendered.chunk.toolName} -
-
-
{JSON.stringify(rendered.chunk.input, null, 2)}
-
-
- {:else} -
- -
- - {rendered.chunk.toolName} - {#if rendered.chunk.isError} - error - {/if} -
-
-
{rendered.chunk.content}
-
-
- {/if} -
-
- {:else} - -
-
- {#if rendered.chunk.type === "text"} - - {:else if rendered.chunk.type === "error"} - - {:else if rendered.chunk.type === "system"} -
{rendered.chunk.text}
- {/if} -
-
- {/if} + {#if rendered.role === "user"} + +
+
+ {#if rendered.chunk.type === "text"} +

{rendered.chunk.text}

+ {/if} +
+
+ {:else if rendered.chunk.type === "thinking"} + +
+
+
+ +
+ {rendered.streaming ? "Thinking" : "Thoughts"} + {#if rendered.streaming} + + {/if} +
+
+

{rendered.chunk.text}

+
+
+
+
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} + +
+
+ {#if rendered.chunk.type === "tool-call"} +
+ +
+ + {rendered.chunk.toolName} +
+
+
{JSON.stringify(
+                  rendered.chunk.input,
+                  null,
+                  2,
+                )}
+
+
+ {:else} +
+ +
+ + {rendered.chunk.toolName} + {#if rendered.chunk.isError} + error + {/if} +
+
+
{rendered.chunk.content}
+
+
+ {/if} +
+
+ {:else} + +
+
+ {#if rendered.chunk.type === "text"} + + {:else if rendered.chunk.type === "error"} + + {:else if rendered.chunk.type === "system"} +
{rendered.chunk.text}
+ {/if} +
+
+ {/if} {/snippet}
- {#if hasEarlier && onShowEarlier} - -
- -
- {/if} - {#each keyedRows as { row, key } (key)} - {#if row.kind === "step-metrics"} - {@const sv = viewStepMetrics(row.step, row.index)} -
-
-
- {sv.label} · {sv.tokensLabel} - {#if sv.tps} · {sv.tps}{/if} - {#if sv.genTotal} · {sv.genTotal}{/if} -
-
-
- {:else if row.kind === "turn-metrics"} - {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} - {@const lastCache = viewCacheRate(row.turn.usage)} - {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} -
-
-
-
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) - {#if turnView.tps} · {turnView.tps}{/if} - {#if turnView.duration} · {turnView.duration}{/if} -
-
- - Last turn: - {lastCache.pct}% - - - Chat Total: - {chatCache.pct}% - - {#if retention} - - Retention: - {retention.pct}% - - {/if} -
-
-
-
- {:else if row.group.kind === "single"} - {@render chunkRow(row.group.chunk)} - {:else} - -
-
-
- {#each row.group.entries as entry (entry.call.toolCallId)} -
- -
- - {entry.call.toolName} - {#if entry.result?.isError} - error - {:else if entry.result === null} - - {/if} -
-
-
{JSON.stringify(entry.call.input, null, 2)}
- {#if entry.result} -
{entry.result.content}
- {/if} -
-
- {/each} -
-
-
- {/if} - {/each} - {#if providerRetry} - {@const rv = viewProviderRetry(providerRetry)} - -
-
-
-
- - {rv.attemptLabel} — retrying in {rv.delayLabel}… - {#if rv.code} - {rv.code} - {/if} -
-
{rv.message}
-
-
-
- {/if} + {#if hasEarlier && onShowEarlier} + +
+ +
+ {/if} + {#each keyedRows as { row, key } (key)} + {#if row.kind === "step-metrics"} + {@const sv = viewStepMetrics(row.step, row.index)} +
+
+
+ {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.genTotal} + · {sv.genTotal}{/if} +
+
+
+ {:else if row.kind === "turn-metrics"} + {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} + {@const lastCache = viewCacheRate(row.turn.usage)} + {@const chatCache = viewCacheRate(row.cumulativeUsage)} + {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} +
+
+
+
+ {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {#if turnView.tps} + · {turnView.tps}{/if} + {#if turnView.duration} + · {turnView.duration}{/if} +
+
+ + Last turn: + {lastCache.pct}% + + + Chat Total: + {chatCache.pct}% + + {#if retention} + + Retention: + {retention.pct}% + + {/if} +
+
+
+
+ {:else if row.group.kind === "single"} + {@render chunkRow(row.group.chunk)} + {:else} + +
+
+
+ {#each row.group.entries as entry (entry.call.toolCallId)} +
+ +
+ + {entry.call.toolName} + {#if entry.result?.isError} + error + {:else if entry.result === null} + + {/if} +
+
+
{JSON.stringify(
+                      entry.call.input,
+                      null,
+                      2,
+                    )}
+ {#if entry.result} +
{entry.result.content}
+ {/if} +
+
+ {/each} +
+
+
+ {/if} + {/each} + {#if providerRetry} + {@const rv = viewProviderRetry(providerRetry)} + +
+
+
+
+ + {rv.attemptLabel} — retrying in {rv.delayLabel}… + {#if rv.code} + {rv.code} + {/if} +
+
{rv.message}
+
+
+
+ {/if}
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte index 7bec984..5014e5c 100644 --- a/src/features/chat/ui/CompactionView.svelte +++ b/src/features/chat/ui/CompactionView.svelte @@ -1,154 +1,154 @@
- -
- Manual compaction - - {#if !canCompact} -

Open or start a conversation to compact its history.

- {:else if compactError} -

{compactError}

- {:else if compactResult} -

- Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. -

- {:else} -

- Summarizes old messages into a system summary + retains the most recent messages. -

- {/if} -
+ +
+ Manual compaction + + {#if !canCompact} +

Open or start a conversation to compact its history.

+ {:else if compactError} +

{compactError}

+ {:else if compactResult} +

+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept. +

+ {:else} +

+ Summarizes old messages into a system summary + retains the most recent messages. +

+ {/if} +
- -
- Auto-compact percent -
- - % - {#if savingPercent} - - {/if} -
-

- Current: {percentLabel} -
- 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. -

- {#if percentError} -

{percentError}

- {:else if percentSaved} -

Saved.

- {/if} -
+ +
+ Auto-compact percent +
+ + % + {#if savingPercent} + + {/if} +
+

+ Current: {percentLabel} +
+ 0 disables auto-compact. Default is {DEFAULT_PERCENT}%. +

+ {#if percentError} +

{percentError}

+ {:else if percentSaved} +

Saved.

+ {/if} +
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index fe9ea94..96b4b3a 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,198 +1,198 @@
{ - e.preventDefault(); - handleSubmit(); - }} + class="flex flex-col" + onsubmit={(e) => { + e.preventDefault(); + handleSubmit(); + }} > - -
- - {#if buttonMode === "stop"} - - {:else} - - {/if} -
+ +
+ + {#if buttonMode === "stop"} + + {:else} + + {/if} +
- -
- - {#if status === "running"} - - {:else if status === "error"} - - - - - - {:else} - - - - {/if} - + +
+ + {#if status === "running"} + + {:else if status === "error"} + + + + + + {:else} + + + + {/if} + - {#if usage.percent !== null} - - {:else} - - {/if} + {#if usage.percent !== null} + + {:else} + + {/if} - - {#if hasUsage} - {formatCompactTokens(usage.current)}{#if usage.max !== null} - / {formatCompactTokens(usage.max)}{/if} - {#if usage.percent !== null} - · {usage.percent.toFixed(1)}% - {/if} - {:else} - — tokens - {/if} - -
+ + {#if hasUsage} + {formatCompactTokens(usage.current)}{#if usage.max !== null} + / {formatCompactTokens(usage.max)}{/if} + {#if usage.percent !== null} + · {usage.percent.toFixed(1)}% + {/if} + {:else} + — tokens + {/if} + +
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index a288cb8..03acb79 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,50 +1,50 @@
- - + +
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte index 8c7b193..d982905 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,75 +1,75 @@
- Reasoning effort -
- - {#if saving} - - {/if} -
- {#if error} -

{error}

- {:else if justSaved} -

Saved — applies from the next turn.

- {:else} -

- How long the model thinks before answering. Changing it can re-prefill the prompt cache once. -

- {/if} + Reasoning effort +
+ + {#if saving} + + {/if} +
+ {#if error} +

{error}

+ {:else if justSaved} +

Saved — applies from the next turn.

+ {:else} +

+ How long the model thinks before answering. Changing it can re-prefill the prompt cache once. +

+ {/if}
diff --git a/src/features/computer/index.ts b/src/features/computer/index.ts index 656f6c2..05e56cc 100644 --- a/src/features/computer/index.ts +++ b/src/features/computer/index.ts @@ -1,31 +1,31 @@ export type { - Badge, - ComputerListResult, - ComputerSaveResult, - ComputerStatusResult, - ComputerStatusView, - ComputerView, - LoadComputerStatus, - LoadComputers, - SaveComputer, - TestComputer, - TestComputerResult, - TestResultView, + Badge, + ComputerListResult, + ComputerSaveResult, + ComputerStatusResult, + ComputerStatusView, + ComputerView, + LoadComputerStatus, + LoadComputers, + SaveComputer, + TestComputer, + TestComputerResult, + TestResultView, } from "./logic/view-model"; export { - formatHost, - knownHostLabel, - summarizeComputers, - viewComputer, - viewComputerStatus, - viewComputers, - viewTestResult, + formatHost, + knownHostLabel, + summarizeComputers, + viewComputer, + viewComputerStatus, + viewComputers, + viewTestResult, } from "./logic/view-model"; export { default as ComputerField } from "./ui/ComputerField.svelte"; export { default as ComputerSelect } from "./ui/ComputerSelect.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "computer", - description: "Per-conversation / per-workspace SSH computer selection + status", + name: "computer", + description: "Per-conversation / per-workspace SSH computer selection + status", } as const; diff --git a/src/features/computer/logic/view-model.test.ts b/src/features/computer/logic/view-model.test.ts index d9b7f74..45d459e 100644 --- a/src/features/computer/logic/view-model.test.ts +++ b/src/features/computer/logic/view-model.test.ts @@ -2,166 +2,166 @@ import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/tra import type { ComputerEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - formatHost, - knownHostLabel, - summarizeComputers, - viewComputer, - viewComputerStatus, - viewComputers, - viewTestResult, + formatHost, + knownHostLabel, + summarizeComputers, + viewComputer, + viewComputerStatus, + viewComputers, + viewTestResult, } from "./view-model"; function computer(overrides: Partial = {}): ComputerEntry { - return { - alias: "buildbox", - hostName: "10.0.0.5", - port: 22, - user: "deploy", - identityFile: "/home/deploy/.ssh/id_ed25519", - knownHost: true, - usageCount: 0, - ...overrides, - }; + return { + alias: "buildbox", + hostName: "10.0.0.5", + port: 22, + user: "deploy", + identityFile: "/home/deploy/.ssh/id_ed25519", + knownHost: true, + usageCount: 0, + ...overrides, + }; } describe("formatHost", () => { - it("is user@host with no port when port is the SSH default (22)", () => { - expect(formatHost(computer({ port: 22 }))).toBe("deploy@10.0.0.5"); - }); + it("is user@host with no port when port is the SSH default (22)", () => { + expect(formatHost(computer({ port: 22 }))).toBe("deploy@10.0.0.5"); + }); - it("appends a non-default port", () => { - expect(formatHost(computer({ port: 2222 }))).toBe("deploy@10.0.0.5:2222"); - }); + it("appends a non-default port", () => { + expect(formatHost(computer({ port: 2222 }))).toBe("deploy@10.0.0.5:2222"); + }); - it("falls back to the alias when hostName is empty", () => { - expect(formatHost(computer({ hostName: "" }))).toBe("deploy@buildbox"); - }); + it("falls back to the alias when hostName is empty", () => { + expect(formatHost(computer({ hostName: "" }))).toBe("deploy@buildbox"); + }); }); describe("knownHostLabel", () => { - it("is 'known host' when known", () => { - expect(knownHostLabel(true)).toBe("known host"); - }); + it("is 'known host' when known", () => { + expect(knownHostLabel(true)).toBe("known host"); + }); - it("is 'new host' when not known", () => { - expect(knownHostLabel(false)).toBe("new host"); - }); + it("is 'new host' when not known", () => { + expect(knownHostLabel(false)).toBe("new host"); + }); }); describe("viewComputer", () => { - it("projects alias + hostSummary + identity + known-host label", () => { - const v = viewComputer(computer()); - expect(v.alias).toBe("buildbox"); - expect(v.hostSummary).toBe("deploy@10.0.0.5"); - expect(v.identityFile).toBe("/home/deploy/.ssh/id_ed25519"); - expect(v.knownHostLabel).toBe("known host"); - expect(v.knownHost).toBe(true); - }); - - it("preserves a null identityFile (default key)", () => { - const v = viewComputer(computer({ identityFile: null })); - expect(v.identityFile).toBeNull(); - }); + it("projects alias + hostSummary + identity + known-host label", () => { + const v = viewComputer(computer()); + expect(v.alias).toBe("buildbox"); + expect(v.hostSummary).toBe("deploy@10.0.0.5"); + expect(v.identityFile).toBe("/home/deploy/.ssh/id_ed25519"); + expect(v.knownHostLabel).toBe("known host"); + expect(v.knownHost).toBe(true); + }); + + it("preserves a null identityFile (default key)", () => { + const v = viewComputer(computer({ identityFile: null })); + expect(v.identityFile).toBeNull(); + }); }); describe("viewComputers", () => { - it("maps each entry (and drops usageCount from the view)", () => { - const views = viewComputers([ - computer({ alias: "a", usageCount: 3 }), - computer({ alias: "b", hostName: "b.local", usageCount: 0 }), - ]); - expect(views).toHaveLength(2); - expect(views.at(0)?.alias).toBe("a"); - expect(views.at(1)?.hostSummary).toBe("deploy@b.local"); - }); + it("maps each entry (and drops usageCount from the view)", () => { + const views = viewComputers([ + computer({ alias: "a", usageCount: 3 }), + computer({ alias: "b", hostName: "b.local", usageCount: 0 }), + ]); + expect(views).toHaveLength(2); + expect(views.at(0)?.alias).toBe("a"); + expect(views.at(1)?.hostSummary).toBe("deploy@b.local"); + }); }); describe("summarizeComputers", () => { - it("is 'No computers discovered' for an empty list", () => { - expect(summarizeComputers([])).toBe("No computers discovered"); - }); + it("is 'No computers discovered' for an empty list", () => { + expect(summarizeComputers([])).toBe("No computers discovered"); + }); - it("is singular for one computer", () => { - expect(summarizeComputers([computer()])).toBe("1 computer"); - }); + it("is singular for one computer", () => { + expect(summarizeComputers([computer()])).toBe("1 computer"); + }); - it("is plural for many", () => { - expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers"); - }); + it("is plural for many", () => { + expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers"); + }); }); describe("viewComputerStatus", () => { - function status( - state: ComputerStatusResponse["state"], - overrides: Partial = {}, - ): ComputerStatusResponse { - return { alias: "buildbox", state, knownHost: true, ...overrides }; - } - - it("connected → success badge, not busy, no error", () => { - const v = viewComputerStatus(status("connected")); - expect(v.statusLabel).toBe("Connected"); - expect(v.badge).toBe("success"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - }); - - it("connecting → warning badge + busy (spinner), no error", () => { - const v = viewComputerStatus(status("connecting")); - expect(v.statusLabel).toBe("Connecting…"); - expect(v.badge).toBe("warning"); - expect(v.busy).toBe(true); - expect(v.error).toBeNull(); - }); - - it("disconnected → neutral badge, NOT busy (a stable idle state)", () => { - const v = viewComputerStatus(status("disconnected")); - expect(v.statusLabel).toBe("Disconnected"); - expect(v.badge).toBe("neutral"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - }); - - it("error → error badge, not busy, surfaces the reason", () => { - const v = viewComputerStatus(status("error", { error: "auth refused" })); - expect(v.statusLabel).toBe("Error"); - expect(v.badge).toBe("error"); - expect(v.busy).toBe(false); - expect(v.error).toBe("auth refused"); - }); - - it("error falls back to a default reason when the backend omits one", () => { - const v = viewComputerStatus(status("error")); - expect(v.error).toBe("Connection failed"); - }); - - it("carries the knownHost flag through", () => { - const v = viewComputerStatus(status("connected", { knownHost: false })); - expect(v.knownHost).toBe(false); - }); + function status( + state: ComputerStatusResponse["state"], + overrides: Partial = {}, + ): ComputerStatusResponse { + return { alias: "buildbox", state, knownHost: true, ...overrides }; + } + + it("connected → success badge, not busy, no error", () => { + const v = viewComputerStatus(status("connected")); + expect(v.statusLabel).toBe("Connected"); + expect(v.badge).toBe("success"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("connecting → warning badge + busy (spinner), no error", () => { + const v = viewComputerStatus(status("connecting")); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.badge).toBe("warning"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); + + it("disconnected → neutral badge, NOT busy (a stable idle state)", () => { + const v = viewComputerStatus(status("disconnected")); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.badge).toBe("neutral"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); + + it("error → error badge, not busy, surfaces the reason", () => { + const v = viewComputerStatus(status("error", { error: "auth refused" })); + expect(v.statusLabel).toBe("Error"); + expect(v.badge).toBe("error"); + expect(v.busy).toBe(false); + expect(v.error).toBe("auth refused"); + }); + + it("error falls back to a default reason when the backend omits one", () => { + const v = viewComputerStatus(status("error")); + expect(v.error).toBe("Connection failed"); + }); + + it("carries the knownHost flag through", () => { + const v = viewComputerStatus(status("connected", { knownHost: false })); + expect(v.knownHost).toBe(false); + }); }); describe("viewTestResult", () => { - it("ok=true → no error, 'Connection OK' label", () => { - const r = viewTestResult({ alias: "buildbox", ok: true } as TestComputerResponse); - expect(r.ok).toBe(true); - expect(r.error).toBeNull(); - expect(r.label).toBe("Connection OK"); - }); - - it("ok=false → surfaces the failure reason", () => { - const r = viewTestResult({ - alias: "buildbox", - ok: false, - error: "host unreachable", - } as TestComputerResponse); - expect(r.ok).toBe(false); - expect(r.error).toBe("host unreachable"); - expect(r.label).toBe("Failed"); - }); - - it("ok=false without a reason falls back to a default", () => { - const r = viewTestResult({ alias: "buildbox", ok: false } as TestComputerResponse); - expect(r.error).toBe("Connection failed"); - }); + it("ok=true → no error, 'Connection OK' label", () => { + const r = viewTestResult({ alias: "buildbox", ok: true } as TestComputerResponse); + expect(r.ok).toBe(true); + expect(r.error).toBeNull(); + expect(r.label).toBe("Connection OK"); + }); + + it("ok=false → surfaces the failure reason", () => { + const r = viewTestResult({ + alias: "buildbox", + ok: false, + error: "host unreachable", + } as TestComputerResponse); + expect(r.ok).toBe(false); + expect(r.error).toBe("host unreachable"); + expect(r.label).toBe("Failed"); + }); + + it("ok=false without a reason falls back to a default", () => { + const r = viewTestResult({ alias: "buildbox", ok: false } as TestComputerResponse); + expect(r.error).toBe("Connection failed"); + }); }); diff --git a/src/features/computer/logic/view-model.ts b/src/features/computer/logic/view-model.ts index 944456b..d489fb5 100644 --- a/src/features/computer/logic/view-model.ts +++ b/src/features/computer/logic/view-model.ts @@ -24,29 +24,29 @@ import type { Computer, ComputerEntry } from "@dispatch/wire"; /** Outcome of `PUT /conversations/:id/computer`; `null` when no real conversation is focused. */ export type ComputerSaveResult = - | { readonly ok: true; readonly computerId: string | null } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly computerId: string | null } + | { readonly ok: false; readonly error: string }; export type SaveComputer = (computerId: string | null) => Promise; /** Outcome of `GET /computers`; `null` when the list couldn't be loaded. */ export type ComputerListResult = - | { readonly ok: true; readonly computers: readonly ComputerEntry[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly computers: readonly ComputerEntry[] } + | { readonly ok: false; readonly error: string }; export type LoadComputers = () => Promise; /** Outcome of `GET /computers/:alias/status`; `null` when no alias / not loaded. */ export type ComputerStatusResult = - | { readonly ok: true; readonly status: ComputerStatusResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly status: ComputerStatusResponse } + | { readonly ok: false; readonly error: string }; export type LoadComputerStatus = (alias: string) => Promise; /** Outcome of `POST /computers/:alias/test`. */ export type TestComputerResult = - | { readonly ok: true; readonly response: TestComputerResponse } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly response: TestComputerResponse } + | { readonly ok: false; readonly error: string }; export type TestComputer = (alias: string) => Promise; @@ -55,16 +55,16 @@ export type TestComputer = (alias: string) => Promise export type Badge = "success" | "warning" | "error" | "neutral"; export interface ComputerView { - /** The SSH config `Host` alias — also the `computerId` users select. */ - readonly alias: string; - /** A compact `user@host:port` connection string (the resolved config values). */ - readonly hostSummary: string; - /** The resolved `IdentityFile`, or `null` = default `~/.ssh/id_*`. */ - readonly identityFile: string | null; - /** Short label for the known-host indicator, e.g. "known host" / "new host". */ - readonly knownHostLabel: string; - /** Whether the host's key is already in `~/.ssh/known_hosts`. */ - readonly knownHost: boolean; + /** The SSH config `Host` alias — also the `computerId` users select. */ + readonly alias: string; + /** A compact `user@host:port` connection string (the resolved config values). */ + readonly hostSummary: string; + /** The resolved `IdentityFile`, or `null` = default `~/.ssh/id_*`. */ + readonly identityFile: string | null; + /** Short label for the known-host indicator, e.g. "known host" / "new host". */ + readonly knownHostLabel: string; + /** Whether the host's key is already in `~/.ssh/known_hosts`. */ + readonly knownHost: boolean; } /** @@ -74,28 +74,28 @@ export interface ComputerView { * (the backend resolves this, but this is defensive). */ export function formatHost(computer: Computer): string { - const host = computer.hostName || computer.alias; - const port = computer.port === 22 ? "" : `:${computer.port}`; - return `${computer.user}@${host}${port}`; + const host = computer.hostName || computer.alias; + const port = computer.port === 22 ? "" : `:${computer.port}`; + return `${computer.user}@${host}${port}`; } /** The display label for the known-host indicator. */ export function knownHostLabel(knownHost: boolean): string { - return knownHost ? "known host" : "new host"; + return knownHost ? "known host" : "new host"; } export function viewComputer(computer: Computer): ComputerView { - return { - alias: computer.alias, - hostSummary: formatHost(computer), - identityFile: computer.identityFile, - knownHostLabel: knownHostLabel(computer.knownHost), - knownHost: computer.knownHost, - }; + return { + alias: computer.alias, + hostSummary: formatHost(computer), + identityFile: computer.identityFile, + knownHostLabel: knownHostLabel(computer.knownHost), + knownHost: computer.knownHost, + }; } export function viewComputers(computers: readonly ComputerEntry[]): readonly ComputerView[] { - return computers.map(viewComputer); + return computers.map(viewComputer); } /** @@ -103,23 +103,23 @@ export function viewComputers(computers: readonly ComputerEntry[]): readonly Com * discovered" (the latter is the expected state until the `ssh` extension lands). */ export function summarizeComputers(computers: readonly ComputerEntry[]): string { - if (computers.length === 0) return "No computers discovered"; - return `${computers.length} computer${computers.length === 1 ? "" : "s"}`; + if (computers.length === 0) return "No computers discovered"; + return `${computers.length} computer${computers.length === 1 ? "" : "s"}`; } // ── Connection status → display view ────────────────────────────────────────── export interface ComputerStatusView { - readonly alias: string; - readonly state: ComputerStatusResponse["state"]; - readonly statusLabel: string; - readonly badge: Badge; - /** True while the state is transient (show a spinner). */ - readonly busy: boolean; - /** The error reason when `state === "error"`, else null. */ - readonly error: string | null; - /** Whether the host's key is already in `~/.ssh/known_hosts`. */ - readonly knownHost: boolean; + readonly alias: string; + readonly state: ComputerStatusResponse["state"]; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Whether the host's key is already in `~/.ssh/known_hosts`. */ + readonly knownHost: boolean; } /** @@ -130,55 +130,55 @@ export interface ComputerStatusView { * idle state, NOT busy), `error`→error. */ export function viewComputerStatus(status: ComputerStatusResponse): ComputerStatusView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (status.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "connecting": - statusLabel = "Connecting…"; - badge = "warning"; - busy = true; - break; - case "disconnected": - statusLabel = "Disconnected"; - badge = "neutral"; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - alias: status.alias, - state: status.state, - statusLabel, - badge, - busy, - error: status.state === "error" ? (status.error ?? "Connection failed") : null, - knownHost: status.knownHost, - }; + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (status.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + alias: status.alias, + state: status.state, + statusLabel, + badge, + busy, + error: status.state === "error" ? (status.error ?? "Connection failed") : null, + knownHost: status.knownHost, + }; } // ── Test-connection result → display view ─────────────────────────────────── export interface TestResultView { - readonly alias: string; - readonly ok: boolean; - /** The failure reason when `ok` is false, else null. */ - readonly error: string | null; - /** A short result label, e.g. "Connection OK" / "Failed". */ - readonly label: string; + readonly alias: string; + readonly ok: boolean; + /** The failure reason when `ok` is false, else null. */ + readonly error: string | null; + /** A short result label, e.g. "Connection OK" / "Failed". */ + readonly label: string; } export function viewTestResult(response: TestComputerResponse): TestResultView { - return { - alias: response.alias, - ok: response.ok, - error: response.ok ? null : (response.error ?? "Connection failed"), - label: response.ok ? "Connection OK" : "Failed", - }; + return { + alias: response.alias, + ok: response.ok, + error: response.ok ? null : (response.error ?? "Connection failed"), + label: response.ok ? "Connection OK" : "Failed", + }; } diff --git a/src/features/computer/ui/ComputerField.svelte b/src/features/computer/ui/ComputerField.svelte index ce19b4d..6b54c88 100644 --- a/src/features/computer/ui/ComputerField.svelte +++ b/src/features/computer/ui/ComputerField.svelte @@ -1,209 +1,211 @@
- Computer (SSH) -
- - {#if saving} - - {/if} -
- - {#if !canEdit} -

Start or open a conversation to set its computer.

- {:else if computerId !== null} - -
- {#if statusView} - - {#if statusView.busy} - - {/if} - {statusView.statusLabel} - - {:else if statusError} - Status error - {:else} - - {/if} - - - - {#if testResult} - - {testResult.ok ? "OK" : testResult.error ?? "Failed"} - - {/if} -
- {#if statusView?.error} -

{statusView.error}

- {/if} - {:else if computers.length === 0} -

- No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer. -

- {:else if justSaved && !error} -

Saved.

- {/if} - - {#if error} -

{error}

- {/if} - -

- Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. Not seen by the agent. -

+ Computer (SSH) +
+ + {#if saving} + + {/if} +
+ + {#if !canEdit} +

Start or open a conversation to set its computer.

+ {:else if computerId !== null} + +
+ {#if statusView} + + {#if statusView.busy} + + {/if} + {statusView.statusLabel} + + {:else if statusError} + Status error + {:else} + + {/if} + + + + {#if testResult} + + {testResult.ok ? "OK" : (testResult.error ?? "Failed")} + + {/if} +
+ {#if statusView?.error} +

{statusView.error}

+ {/if} + {:else if computers.length === 0} +

+ No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer. +

+ {:else if justSaved && !error} +

Saved.

+ {/if} + + {#if error} +

{error}

+ {/if} + +

+ Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. + Not seen by the agent. +

diff --git a/src/features/computer/ui/ComputerSelect.svelte b/src/features/computer/ui/ComputerSelect.svelte index d693140..c580b60 100644 --- a/src/features/computer/ui/ComputerSelect.svelte +++ b/src/features/computer/ui/ComputerSelect.svelte @@ -1,39 +1,39 @@ diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts index 89e81b8..ce1c60c 100644 --- a/src/features/conversation-cache/cache.test.ts +++ b/src/features/conversation-cache/cache.test.ts @@ -4,9 +4,9 @@ import { createConversationCache } from "./cache"; import type { ConversationCacheIndexEntry, ConversationChunkStore } from "./types"; const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ - seq, - role, - chunk: { type: "text", text: `chunk-${seq}` }, + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, }); /** @@ -14,184 +14,184 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => * An outermost edge: simulates the storage port without any real I/O. */ function createFakeStore(): ConversationChunkStore { - const store = new Map(); - - return { - async load(conversationId) { - return store.get(conversationId) ?? []; - }, - - async append(conversationId, chunks) { - const existing = store.get(conversationId) ?? []; - const existingSeqs = new Set(existing.map((c) => c.seq)); - const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); - store.set( - conversationId, - [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), - ); - }, - - async delete(conversationId) { - store.delete(conversationId); - }, - - async index() { - const entries: ConversationCacheIndexEntry[] = []; - for (const [id, chunks] of store) { - if (chunks.length === 0) continue; - let maxSeq = 0; - for (const c of chunks) { - if (c.seq > maxSeq) maxSeq = c.seq; - } - entries.push({ - conversationId: id, - chunkCount: chunks.length, - maxSeq, - }); - } - return entries; - }, - }; + const store = new Map(); + + return { + async load(conversationId) { + return store.get(conversationId) ?? []; + }, + + async append(conversationId, chunks) { + const existing = store.get(conversationId) ?? []; + const existingSeqs = new Set(existing.map((c) => c.seq)); + const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq)); + store.set( + conversationId, + [...existing, ...toAdd].sort((a, b) => a.seq - b.seq), + ); + }, + + async delete(conversationId) { + store.delete(conversationId); + }, + + async index() { + const entries: ConversationCacheIndexEntry[] = []; + for (const [id, chunks] of store) { + if (chunks.length === 0) continue; + let maxSeq = 0; + for (const c of chunks) { + if (c.seq > maxSeq) maxSeq = c.seq; + } + entries.push({ + conversationId: id, + chunkCount: chunks.length, + maxSeq, + }); + } + return entries; + }, + }; } describe("cache.load", () => { - it("returns stored chunks", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - const result = await cache.load("conv-1"); - expect(result).toEqual([chunk(1), chunk(2)]); - }); - - it("returns empty array for absent conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - const result = await cache.load("nonexistent"); - expect(result).toEqual([]); - }); + it("returns stored chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + const result = await cache.load("conv-1"); + expect(result).toEqual([chunk(1), chunk(2)]); + }); + + it("returns empty array for absent conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + const result = await cache.load("nonexistent"); + expect(result).toEqual([]); + }); }); describe("cache.commit", () => { - it("appends only new chunks", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - - const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); - expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); - - // Verify store has all chunks - const stored = await store.load("conv-1"); - expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); - }); - - it("returns full merged result", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - - const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); - expect(merged).toEqual([chunk(1), chunk(3)]); - }); - - it("is idempotent — re-committing same chunks is a no-op", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - - await cache.commit("conv-1", [chunk(1), chunk(2)]); - const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); - expect(merged).toEqual([chunk(1), chunk(2)]); - - const stored = await store.load("conv-1"); - expect(stored).toEqual([chunk(1), chunk(2)]); - }); + it("appends only new chunks", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(2)]); + + const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]); + expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]); + + // Verify store has all chunks + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("returns full merged result", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]); + expect(merged).toEqual([chunk(1), chunk(3)]); + }); + + it("is idempotent — re-committing same chunks is a no-op", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + + await cache.commit("conv-1", [chunk(1), chunk(2)]); + const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]); + expect(merged).toEqual([chunk(1), chunk(2)]); + + const stored = await store.load("conv-1"); + expect(stored).toEqual([chunk(1), chunk(2)]); + }); }); describe("cache.sinceSeq", () => { - it("returns max seq from cache", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); - expect(await cache.sinceSeq("conv-1")).toBe(5); - }); - - it("returns 0 for empty conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); - expect(await cache.sinceSeq("conv-1")).toBe(0); - }); + it("returns max seq from cache", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]); + expect(await cache.sinceSeq("conv-1")).toBe(5); + }); + + it("returns 0 for empty conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + expect(await cache.sinceSeq("conv-1")).toBe(0); + }); }); describe("cache.evictIfOverBudget", () => { - it("deletes selected conversations", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 5 }); - - await store.append("a", [chunk(1), chunk(2)]); - await store.append("b", [chunk(1), chunk(2)]); - await store.append("c", [chunk(1)]); - - // Total = 5, max = 5, under budget - const evicted = await cache.evictIfOverBudget(null); - expect(evicted).toEqual([]); - - // Add more to go over budget - await store.append("d", [chunk(1), chunk(2), chunk(3)]); - // Total = 8, max = 5, need to evict 3+ chunks - - const evicted2 = await cache.evictIfOverBudget(null); - expect(evicted2.length).toBeGreaterThan(0); - - // Verify evicted conversations are deleted - for (const id of evicted2) { - expect(await store.load(id)).toEqual([]); - } - }); - - it("never evicts the active conversation", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 3 }); - - await store.append("active", [chunk(1), chunk(2), chunk(3)]); - await store.append("other", [chunk(1), chunk(2)]); - - // Total = 5, max = 3, need to evict 2+ chunks - const evicted = await cache.evictIfOverBudget("active"); - expect(evicted).not.toContain("active"); - expect(evicted).toContain("other"); - }); - - it("returns empty when under budget", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store, { maxChunks: 100 }); - - await store.append("a", [chunk(1)]); - await store.append("b", [chunk(1)]); - - const evicted = await cache.evictIfOverBudget(null); - expect(evicted).toEqual([]); - }); + it("deletes selected conversations", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 5 }); + + await store.append("a", [chunk(1), chunk(2)]); + await store.append("b", [chunk(1), chunk(2)]); + await store.append("c", [chunk(1)]); + + // Total = 5, max = 5, under budget + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + + // Add more to go over budget + await store.append("d", [chunk(1), chunk(2), chunk(3)]); + // Total = 8, max = 5, need to evict 3+ chunks + + const evicted2 = await cache.evictIfOverBudget(null); + expect(evicted2.length).toBeGreaterThan(0); + + // Verify evicted conversations are deleted + for (const id of evicted2) { + expect(await store.load(id)).toEqual([]); + } + }); + + it("never evicts the active conversation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 3 }); + + await store.append("active", [chunk(1), chunk(2), chunk(3)]); + await store.append("other", [chunk(1), chunk(2)]); + + // Total = 5, max = 3, need to evict 2+ chunks + const evicted = await cache.evictIfOverBudget("active"); + expect(evicted).not.toContain("active"); + expect(evicted).toContain("other"); + }); + + it("returns empty when under budget", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store, { maxChunks: 100 }); + + await store.append("a", [chunk(1)]); + await store.append("b", [chunk(1)]); + + const evicted = await cache.evictIfOverBudget(null); + expect(evicted).toEqual([]); + }); }); describe("cache.delete", () => { - it("removes the conversation from the store", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); + it("removes the conversation from the store", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); - await store.append("conv-1", [chunk(1), chunk(2)]); - await cache.delete("conv-1"); + await store.append("conv-1", [chunk(1), chunk(2)]); + await cache.delete("conv-1"); - const stored = await store.load("conv-1"); - expect(stored).toEqual([]); - }); + const stored = await store.load("conv-1"); + expect(stored).toEqual([]); + }); - it("then load returns []", async () => { - const store = createFakeStore(); - const cache = createConversationCache(store); + it("then load returns []", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); - await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); - await cache.delete("conv-1"); + await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]); + await cache.delete("conv-1"); - const result = await cache.load("conv-1"); - expect(result).toEqual([]); - }); + const result = await cache.load("conv-1"); + expect(result).toEqual([]); + }); }); diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts index 3d5743a..4953944 100644 --- a/src/features/conversation-cache/cache.ts +++ b/src/features/conversation-cache/cache.ts @@ -3,31 +3,31 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationChunkStore } from "./types"; export interface ConversationCache { - /** Load all cached chunks for a conversation. */ - load(conversationId: string): Promise; + /** Load all cached chunks for a conversation. */ + load(conversationId: string): Promise; - /** - * Load + reconcile + append new chunks. - * Returns the merged cache (the new authoritative cache for this conversation). - */ - commit(conversationId: string, incoming: readonly StoredChunk[]): Promise; + /** + * Load + reconcile + append new chunks. + * Returns the merged cache (the new authoritative cache for this conversation). + */ + commit(conversationId: string, incoming: readonly StoredChunk[]): Promise; - /** Return the `?sinceSeq=` cursor for the next incremental sync. */ - sinceSeq(conversationId: string): Promise; + /** Return the `?sinceSeq=` cursor for the next incremental sync. */ + sinceSeq(conversationId: string): Promise; - /** - * Evict conversations over budget. - * Returns the evicted conversationIds. - */ - evictIfOverBudget(activeConversationId: string | null): Promise; + /** + * Evict conversations over budget. + * Returns the evicted conversationIds. + */ + evictIfOverBudget(activeConversationId: string | null): Promise; - /** Delete all cached data for a single conversation (local forget). */ - delete(conversationId: string): Promise; + /** Delete all cached data for a single conversation (local forget). */ + delete(conversationId: string): Promise; } export interface ConversationCacheOptions { - /** Maximum total chunks across all conversations before eviction triggers. */ - readonly maxChunks?: number; + /** Maximum total chunks across all conversations before eviction triggers. */ + readonly maxChunks?: number; } const DEFAULT_MAX_CHUNKS = 10_000; @@ -38,41 +38,41 @@ const DEFAULT_MAX_CHUNKS = 10_000; * The ONLY impurity is the injected `store`; all logic delegates to pure functions. */ export function createConversationCache( - store: ConversationChunkStore, - opts?: ConversationCacheOptions, + store: ConversationChunkStore, + opts?: ConversationCacheOptions, ): ConversationCache { - const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; + const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; - return { - async load(conversationId) { - return store.load(conversationId); - }, + return { + async load(conversationId) { + return store.load(conversationId); + }, - async commit(conversationId, incoming) { - const cached = await store.load(conversationId); - const { merged, toAppend } = reconcileCache(cached, incoming); - if (toAppend.length > 0) { - await store.append(conversationId, toAppend); - } - return merged; - }, + async commit(conversationId, incoming) { + const cached = await store.load(conversationId); + const { merged, toAppend } = reconcileCache(cached, incoming); + if (toAppend.length > 0) { + await store.append(conversationId, toAppend); + } + return merged; + }, - async sinceSeq(conversationId) { - const cached = await store.load(conversationId); - return nextSinceSeq(cached); - }, + async sinceSeq(conversationId) { + const cached = await store.load(conversationId); + return nextSinceSeq(cached); + }, - async evictIfOverBudget(activeConversationId) { - const idx = await store.index(); - const toEvict = selectEvictions(idx, { maxChunks, activeConversationId }); - for (const id of toEvict) { - await store.delete(id); - } - return toEvict; - }, + async evictIfOverBudget(activeConversationId) { + const idx = await store.index(); + const toEvict = selectEvictions(idx, { maxChunks, activeConversationId }); + for (const id of toEvict) { + await store.delete(id); + } + return toEvict; + }, - async delete(conversationId) { - await store.delete(conversationId); - }, - }; + async delete(conversationId) { + await store.delete(conversationId); + }, + }; } diff --git a/src/features/conversation-cache/index.ts b/src/features/conversation-cache/index.ts index 32e32d9..25e2af7 100644 --- a/src/features/conversation-cache/index.ts +++ b/src/features/conversation-cache/index.ts @@ -2,13 +2,13 @@ export type { ConversationCache, ConversationCacheOptions } from "./cache"; export { createConversationCache } from "./cache"; export { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; export type { - ConversationCacheIndexEntry, - ConversationChunkStore, - ReconcileResult, + ConversationCacheIndexEntry, + ConversationChunkStore, + ReconcileResult, } from "./types"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "conversation-cache", - description: "IndexedDB-backed chunk cache with reconciliation", + name: "conversation-cache", + description: "IndexedDB-backed chunk cache with reconciliation", } as const; diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 858460a..880c32e 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -4,137 +4,137 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationCacheIndexEntry } from "./types"; const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({ - seq, - role, - chunk: { type: "text", text: `chunk-${seq}` }, + seq, + role, + chunk: { type: "text", text: `chunk-${seq}` }, }); describe("reconcileCache", () => { - it("merges and dedupes by seq", () => { - const cached = [chunk(1), chunk(2)]; - const incoming = [chunk(2), chunk(3)]; - const result = reconcileCache(cached, incoming); - expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); - }); - - it("toAppend excludes already-cached seqs", () => { - const cached = [chunk(1), chunk(2)]; - const incoming = [chunk(2), chunk(3)]; - const result = reconcileCache(cached, incoming); - expect(result.toAppend).toEqual([chunk(3)]); - }); - - it("tolerates out-of-order incoming", () => { - const cached = [chunk(1)]; - const incoming = [chunk(5), chunk(3), chunk(2)]; - const result = reconcileCache(cached, incoming); - expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); - expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); - }); - - it("returns empty merged and toAppend when both inputs are empty", () => { - const result = reconcileCache([], []); - expect(result.merged).toEqual([]); - expect(result.toAppend).toEqual([]); - }); - - it("handles empty cached with incoming", () => { - const incoming = [chunk(3), chunk(1)]; - const result = reconcileCache([], incoming); - expect(result.merged).toEqual([chunk(1), chunk(3)]); - expect(result.toAppend).toEqual([chunk(3), chunk(1)]); - }); - - it("handles cached with empty incoming", () => { - const cached = [chunk(1), chunk(2)]; - const result = reconcileCache(cached, []); - expect(result.merged).toEqual([chunk(1), chunk(2)]); - expect(result.toAppend).toEqual([]); - }); - - it("is idempotent — re-reconciling same incoming produces same result", () => { - const cached = [chunk(1)]; - const incoming = [chunk(2), chunk(3)]; - const first = reconcileCache(cached, incoming); - const second = reconcileCache(first.merged, incoming); - expect(second.merged).toEqual(first.merged); - expect(second.toAppend).toEqual([]); - }); + it("merges and dedupes by seq", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]); + }); + + it("toAppend excludes already-cached seqs", () => { + const cached = [chunk(1), chunk(2)]; + const incoming = [chunk(2), chunk(3)]; + const result = reconcileCache(cached, incoming); + expect(result.toAppend).toEqual([chunk(3)]); + }); + + it("tolerates out-of-order incoming", () => { + const cached = [chunk(1)]; + const incoming = [chunk(5), chunk(3), chunk(2)]; + const result = reconcileCache(cached, incoming); + expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]); + expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]); + }); + + it("returns empty merged and toAppend when both inputs are empty", () => { + const result = reconcileCache([], []); + expect(result.merged).toEqual([]); + expect(result.toAppend).toEqual([]); + }); + + it("handles empty cached with incoming", () => { + const incoming = [chunk(3), chunk(1)]; + const result = reconcileCache([], incoming); + expect(result.merged).toEqual([chunk(1), chunk(3)]); + expect(result.toAppend).toEqual([chunk(3), chunk(1)]); + }); + + it("handles cached with empty incoming", () => { + const cached = [chunk(1), chunk(2)]; + const result = reconcileCache(cached, []); + expect(result.merged).toEqual([chunk(1), chunk(2)]); + expect(result.toAppend).toEqual([]); + }); + + it("is idempotent — re-reconciling same incoming produces same result", () => { + const cached = [chunk(1)]; + const incoming = [chunk(2), chunk(3)]; + const first = reconcileCache(cached, incoming); + const second = reconcileCache(first.merged, incoming); + expect(second.merged).toEqual(first.merged); + expect(second.toAppend).toEqual([]); + }); }); describe("nextSinceSeq", () => { - it("returns max seq", () => { - const cached = [chunk(1), chunk(5), chunk(3)]; - expect(nextSinceSeq(cached)).toBe(5); - }); - - it("returns 0 when empty", () => { - expect(nextSinceSeq([])).toBe(0); - }); - - it("returns single seq for single chunk", () => { - expect(nextSinceSeq([chunk(42)])).toBe(42); - }); + it("returns max seq", () => { + const cached = [chunk(1), chunk(5), chunk(3)]; + expect(nextSinceSeq(cached)).toBe(5); + }); + + it("returns 0 when empty", () => { + expect(nextSinceSeq([])).toBe(0); + }); + + it("returns single seq for single chunk", () => { + expect(nextSinceSeq([chunk(42)])).toBe(42); + }); }); describe("selectEvictions", () => { - it("never evicts the active conversation", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, - { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, - ]; - const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); - expect(result).not.toContain("active"); - expect(result).toContain("other"); - }); - - it("evicts LRU until under budget", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, - { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, - { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, - { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, - ]; - // Total = 120, max = 60, need to evict 60+ chunks - // LRU order: d(10), b(50), a(100), c(200) - const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); - expect(result).toEqual(["d", "b"]); - }); - - it("is a no-op under budget", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, - { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, - ]; - const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); - expect(result).toEqual([]); - }); - - it("returns empty for empty index", () => { - const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); - expect(result).toEqual([]); - }); - - it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, - { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, - { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, - ]; - // Total = 90, max = 60, need to evict 30+ chunks - // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) - const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); - expect(result).toEqual(["b"]); - }); - - it("handles missing lastAccess (treated as 0)", () => { - const index: ConversationCacheIndexEntry[] = [ - { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, - { conversationId: "b", chunkCount: 30, maxSeq: 30 }, - ]; - // Total = 60, max = 30, need to evict 30+ chunks - // b has no lastAccess (0), a has 100 - const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); - expect(result).toEqual(["b"]); - }); + it("never evicts the active conversation", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 }, + { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 }, + ]; + const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" }); + expect(result).not.toContain("active"); + expect(result).toContain("other"); + }); + + it("evicts LRU until under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 }, + { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 }, + ]; + // Total = 120, max = 60, need to evict 60+ chunks + // LRU order: d(10), b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["d", "b"]); + }); + + it("is a no-op under budget", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 }, + { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 }, + ]; + const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("returns empty for empty index", () => { + const result = selectEvictions([], { maxChunks: 100, activeConversationId: null }); + expect(result).toEqual([]); + }); + + it("tie-breaks by smaller maxSeq when lastAccess is equal", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 }, + { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 }, + { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 }, + ]; + // Total = 90, max = 60, need to evict 30+ chunks + // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200) + const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); + + it("handles missing lastAccess (treated as 0)", () => { + const index: ConversationCacheIndexEntry[] = [ + { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 }, + { conversationId: "b", chunkCount: 30, maxSeq: 30 }, + ]; + // Total = 60, max = 30, need to evict 30+ chunks + // b has no lastAccess (0), a has 100 + const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null }); + expect(result).toEqual(["b"]); + }); }); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 4a4479e..3cb23e2 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -9,24 +9,24 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( - cached: readonly StoredChunk[], - incoming: readonly StoredChunk[], + cached: readonly StoredChunk[], + incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set(); - for (const chunk of cached) { - seen.add(chunk.seq); - } + const seen = new Set(); + for (const chunk of cached) { + seen.add(chunk.seq); + } - const toAppend: StoredChunk[] = []; - for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { - toAppend.push(chunk); - seen.add(chunk.seq); - } - } + const toAppend: StoredChunk[] = []; + for (const chunk of incoming) { + if (!seen.has(chunk.seq)) { + toAppend.push(chunk); + seen.add(chunk.seq); + } + } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); - return { merged, toAppend }; + const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + return { merged, toAppend }; } /** @@ -34,12 +34,12 @@ export function reconcileCache( * This is the `?sinceSeq=` cursor for the next incremental sync. */ export function nextSinceSeq(cached: readonly StoredChunk[]): number { - if (cached.length === 0) return 0; - let max = 0; - for (const chunk of cached) { - if (chunk.seq > max) max = chunk.seq; - } - return max; + if (cached.length === 0) return 0; + let max = 0; + for (const chunk of cached) { + if (chunk.seq > max) max = chunk.seq; + } + return max; } /** @@ -50,28 +50,28 @@ export function nextSinceSeq(cached: readonly StoredChunk[]): number { * Returns [] when under budget. */ export function selectEvictions( - index: readonly ConversationCacheIndexEntry[], - opts: { maxChunks: number; activeConversationId: string | null }, + index: readonly ConversationCacheIndexEntry[], + opts: { maxChunks: number; activeConversationId: string | null }, ): readonly string[] { - const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0); - if (totalChunks <= opts.maxChunks) return []; + const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0); + if (totalChunks <= opts.maxChunks) return []; - const candidates = index - .filter((entry) => entry.conversationId !== opts.activeConversationId) - .sort((a, b) => { - const aAccess = a.lastAccess ?? 0; - const bAccess = b.lastAccess ?? 0; - if (aAccess !== bAccess) return aAccess - bAccess; - return a.maxSeq - b.maxSeq; - }); + const candidates = index + .filter((entry) => entry.conversationId !== opts.activeConversationId) + .sort((a, b) => { + const aAccess = a.lastAccess ?? 0; + const bAccess = b.lastAccess ?? 0; + if (aAccess !== bAccess) return aAccess - bAccess; + return a.maxSeq - b.maxSeq; + }); - let remaining = totalChunks; - const evictions: string[] = []; - for (const entry of candidates) { - if (remaining <= opts.maxChunks) break; - evictions.push(entry.conversationId); - remaining -= entry.chunkCount; - } + let remaining = totalChunks; + const evictions: string[] = []; + for (const entry of candidates) { + if (remaining <= opts.maxChunks) break; + evictions.push(entry.conversationId); + remaining -= entry.chunkCount; + } - return evictions; + return evictions; } diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts index 2a349cc..345ab71 100644 --- a/src/features/conversation-cache/types.ts +++ b/src/features/conversation-cache/types.ts @@ -2,10 +2,10 @@ import type { StoredChunk } from "@dispatch/wire"; /** Metadata entry for a cached conversation, used by eviction logic. */ export interface ConversationCacheIndexEntry { - readonly conversationId: string; - readonly chunkCount: number; - readonly maxSeq: number; - readonly lastAccess?: number; + readonly conversationId: string; + readonly chunkCount: number; + readonly maxSeq: number; + readonly lastAccess?: number; } /** @@ -17,26 +17,26 @@ export interface ConversationCacheIndexEntry { * All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op. */ export interface ConversationChunkStore { - /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ - load(conversationId: string): Promise; + /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ + load(conversationId: string): Promise; - /** - * Append committed chunks to a conversation's cache. - * MUST be idempotent on `seq`: re-appending an existing seq is a no-op. - */ - append(conversationId: string, chunks: readonly StoredChunk[]): Promise; + /** + * Append committed chunks to a conversation's cache. + * MUST be idempotent on `seq`: re-appending an existing seq is a no-op. + */ + append(conversationId: string, chunks: readonly StoredChunk[]): Promise; - /** Delete all cached data for a conversation. */ - delete(conversationId: string): Promise; + /** Delete all cached data for a conversation. */ + delete(conversationId: string): Promise; - /** Return metadata for all cached conversations (for eviction). */ - index(): Promise; + /** Return metadata for all cached conversations (for eviction). */ + index(): Promise; } /** Result of reconciling cached chunks with incoming authoritative chunks. */ export interface ReconcileResult { - /** The merged, deduplicated, seq-ordered chunk list. */ - readonly merged: readonly StoredChunk[]; - /** The subset of incoming chunks that need to be appended (not already cached). */ - readonly toAppend: readonly StoredChunk[]; + /** The merged, deduplicated, seq-ordered chunk list. */ + readonly merged: readonly StoredChunk[]; + /** The subset of incoming chunks that need to be appended (not already cached). */ + readonly toAppend: readonly StoredChunk[]; } diff --git a/src/features/cwd-lsp/index.ts b/src/features/cwd-lsp/index.ts index 0c61425..36ea1b2 100644 --- a/src/features/cwd-lsp/index.ts +++ b/src/features/cwd-lsp/index.ts @@ -1,14 +1,14 @@ export type { - CwdSaveResult, - LoadLspStatus, - LspStatusResult, - SaveCwd, + CwdSaveResult, + LoadLspStatus, + LspStatusResult, + SaveCwd, } from "./logic/view-model"; export { default as CwdField } from "./ui/CwdField.svelte"; export { default as LspStatusView } from "./ui/LspStatusView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "cwd-lsp", - description: "Per-conversation working directory + language-server status", + name: "cwd-lsp", + description: "Per-conversation working directory + language-server status", } as const; diff --git a/src/features/cwd-lsp/logic/view-model.test.ts b/src/features/cwd-lsp/logic/view-model.test.ts index a06edeb..7bc0aad 100644 --- a/src/features/cwd-lsp/logic/view-model.test.ts +++ b/src/features/cwd-lsp/logic/view-model.test.ts @@ -1,101 +1,101 @@ import type { LspServerInfo } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { - cwdChanged, - isSubmittableCwd, - normalizeCwd, - summarizeServers, - viewLspServer, - viewLspServers, + cwdChanged, + isSubmittableCwd, + normalizeCwd, + summarizeServers, + viewLspServer, + viewLspServers, } from "./view-model"; const server = (over: Partial = {}): LspServerInfo => ({ - id: "typescript", - name: "TypeScript", - root: "/home/me/project", - extensions: [".ts", ".tsx"], - state: "connected", - ...over, + id: "typescript", + name: "TypeScript", + root: "/home/me/project", + extensions: [".ts", ".tsx"], + state: "connected", + ...over, }); describe("cwd helpers", () => { - it("normalizeCwd trims surrounding whitespace", () => { - expect(normalizeCwd(" /a/b ")).toBe("/a/b"); - expect(normalizeCwd("\t/x\n")).toBe("/x"); - }); + it("normalizeCwd trims surrounding whitespace", () => { + expect(normalizeCwd(" /a/b ")).toBe("/a/b"); + expect(normalizeCwd("\t/x\n")).toBe("/x"); + }); - it("isSubmittableCwd is false for empty / whitespace-only", () => { - expect(isSubmittableCwd("")).toBe(false); - expect(isSubmittableCwd(" ")).toBe(false); - expect(isSubmittableCwd("/a")).toBe(true); - }); + it("isSubmittableCwd is false for empty / whitespace-only", () => { + expect(isSubmittableCwd("")).toBe(false); + expect(isSubmittableCwd(" ")).toBe(false); + expect(isSubmittableCwd("/a")).toBe(true); + }); - it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { - expect(cwdChanged("/a/b", null)).toBe(true); - expect(cwdChanged("/a/b", "/a/b")).toBe(false); - expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change - expect(cwdChanged("/a/c", "/a/b")).toBe(true); - expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) - expect(cwdChanged(" ", null)).toBe(false); - }); + it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { + expect(cwdChanged("/a/b", null)).toBe(true); + expect(cwdChanged("/a/b", "/a/b")).toBe(false); + expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change + expect(cwdChanged("/a/c", "/a/b")).toBe(true); + expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) + expect(cwdChanged(" ", null)).toBe(false); + }); }); describe("viewLspServer", () => { - it("connected → success badge, not busy, no error", () => { - const v = viewLspServer(server({ state: "connected" })); - expect(v.badge).toBe("success"); - expect(v.statusLabel).toBe("Connected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - expect(v.extensionsLabel).toBe(".ts .tsx"); - }); + it("connected → success badge, not busy, no error", () => { + const v = viewLspServer(server({ state: "connected" })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.extensionsLabel).toBe(".ts .tsx"); + }); - it("starting / not-started → busy (spinner) with warning / neutral badge", () => { - const starting = viewLspServer(server({ state: "starting" })); - expect(starting.badge).toBe("warning"); - expect(starting.busy).toBe(true); + it("starting / not-started → busy (spinner) with warning / neutral badge", () => { + const starting = viewLspServer(server({ state: "starting" })); + expect(starting.badge).toBe("warning"); + expect(starting.busy).toBe(true); - const notStarted = viewLspServer(server({ state: "not-started" })); - expect(notStarted.badge).toBe("neutral"); - expect(notStarted.busy).toBe(true); - }); + const notStarted = viewLspServer(server({ state: "not-started" })); + expect(notStarted.badge).toBe("neutral"); + expect(notStarted.busy).toBe(true); + }); - it("error → error badge + surfaces the reason (with a fallback)", () => { - const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); - expect(withReason.badge).toBe("error"); - expect(withReason.busy).toBe(false); - expect(withReason.error).toBe("ENOENT"); + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT"); - const noReason = viewLspServer(server({ state: "error" })); - expect(noReason.error).toBe("Failed to start"); - }); + const noReason = viewLspServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to start"); + }); - it("viewLspServers maps a list preserving order", () => { - const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); - expect(views.map((v) => v.id)).toEqual(["a", "b"]); - }); + it("viewLspServers maps a list preserving order", () => { + const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); }); describe("summarizeServers", () => { - it("empty list", () => { - expect(summarizeServers([])).toBe("No language servers"); - }); + it("empty list", () => { + expect(summarizeServers([])).toBe("No language servers"); + }); - it("counts connected / starting / errors", () => { - expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "error" }), - ]), - ).toBe("1 connected, 1 error"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "starting" }), - server({ id: "c", state: "error" }), - server({ id: "d", state: "error" }), - ]), - ).toBe("1 connected, 1 starting, 2 errors"); - }); + it("counts connected / starting / errors", () => { + expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "starting" }), + server({ id: "c", state: "error" }), + server({ id: "d", state: "error" }), + ]), + ).toBe("1 connected, 1 starting, 2 errors"); + }); }); diff --git a/src/features/cwd-lsp/logic/view-model.ts b/src/features/cwd-lsp/logic/view-model.ts index 97a3aca..7642418 100644 --- a/src/features/cwd-lsp/logic/view-model.ts +++ b/src/features/cwd-lsp/logic/view-model.ts @@ -16,15 +16,15 @@ import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract /** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */ export type CwdSaveResult = - | { readonly ok: true; readonly cwd: string | null } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null } + | { readonly ok: false; readonly error: string }; export type SaveCwd = (cwd: string) => Promise; /** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */ export type LspStatusResult = - | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } + | { readonly ok: false; readonly error: string }; export type LoadLspStatus = () => Promise; @@ -32,12 +32,12 @@ export type LoadLspStatus = () => Promise; /** Trim surrounding whitespace; the backend rejects an empty cwd. */ export function normalizeCwd(raw: string): string { - return raw.trim(); + return raw.trim(); } /** Whether a typed cwd is submittable (non-empty after trim). */ export function isSubmittableCwd(raw: string): boolean { - return normalizeCwd(raw).length > 0; + return normalizeCwd(raw).length > 0; } /** @@ -45,9 +45,9 @@ export function isSubmittableCwd(raw: string): boolean { * (unchanged, or empty) should be disabled. */ export function cwdChanged(typed: string, current: string | null): boolean { - const next = normalizeCwd(typed); - if (next.length === 0) return false; - return next !== (current ?? ""); + const next = normalizeCwd(typed); + if (next.length === 0) return false; + return next !== (current ?? ""); } // ── LSP server status → display view ────────────────────────────────────────── @@ -55,76 +55,76 @@ export function cwdChanged(typed: string, current: string | null): boolean { export type Badge = "success" | "warning" | "error" | "neutral"; export interface LspServerView { - readonly id: string; - readonly name: string; - readonly root: string; - /** Space-joined extension list, e.g. ".ts .tsx". */ - readonly extensionsLabel: string; - readonly state: LspServerState; - readonly statusLabel: string; - readonly badge: Badge; - /** True while the state is transient (show a spinner). */ - readonly busy: boolean; - /** The error reason when `state === "error"`, else null. */ - readonly error: string | null; + readonly id: string; + readonly name: string; + readonly root: string; + /** Space-joined extension list, e.g. ".ts .tsx". */ + readonly extensionsLabel: string; + readonly state: LspServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; } /** Map a server's state to a display label + badge severity + busy flag. */ export function viewLspServer(server: LspServerInfo): LspServerView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (server.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "starting": - statusLabel = "Starting…"; - badge = "warning"; - busy = true; - break; - case "not-started": - statusLabel = "Not started"; - badge = "neutral"; - busy = true; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - id: server.id, - name: server.name, - root: server.root, - extensionsLabel: server.extensions.join(" "), - state: server.state, - statusLabel, - badge, - busy, - error: server.state === "error" ? (server.error ?? "Failed to start") : null, - }; + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "starting": + statusLabel = "Starting…"; + badge = "warning"; + busy = true; + break; + case "not-started": + statusLabel = "Not started"; + badge = "neutral"; + busy = true; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + name: server.name, + root: server.root, + extensionsLabel: server.extensions.join(" "), + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to start") : null, + }; } export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] { - return servers.map(viewLspServer); + return servers.map(viewLspServer); } /** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */ export function summarizeServers(servers: readonly LspServerInfo[]): string { - if (servers.length === 0) return "No language servers"; - let connected = 0; - let errored = 0; - let pending = 0; - for (const s of servers) { - if (s.state === "connected") connected++; - else if (s.state === "error") errored++; - else pending++; - } - const parts: string[] = []; - if (connected > 0) parts.push(`${connected} connected`); - if (pending > 0) parts.push(`${pending} starting`); - if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); - return parts.join(", "); + if (servers.length === 0) return "No language servers"; + let connected = 0; + let errored = 0; + let pending = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else pending++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (pending > 0) parts.push(`${pending} starting`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); } diff --git a/src/features/cwd-lsp/ui/CwdField.svelte b/src/features/cwd-lsp/ui/CwdField.svelte index bd8b870..c83f5ce 100644 --- a/src/features/cwd-lsp/ui/CwdField.svelte +++ b/src/features/cwd-lsp/ui/CwdField.svelte @@ -1,96 +1,96 @@
- Working directory -
- { - if (e.key === "Enter") handleSave(); - }} - aria-label="Working directory" - /> - -
- {#if !canEdit} -

Start or open a conversation to set its working directory.

- {:else if error} -

{error}

- {:else if justSaved && !dirty} -

Saved.

- {:else} -

Defaults each turn's cwd; drives the language servers below.

- {/if} + Working directory +
+ { + if (e.key === "Enter") handleSave(); + }} + aria-label="Working directory" + /> + +
+ {#if !canEdit} +

Start or open a conversation to set its working directory.

+ {:else if error} +

{error}

+ {:else if justSaved && !dirty} +

Saved.

+ {:else} +

Defaults each turn's cwd; drives the language servers below.

+ {/if}
diff --git a/src/features/cwd-lsp/ui/LspStatusView.svelte b/src/features/cwd-lsp/ui/LspStatusView.svelte index 77603a1..c8c9ea8 100644 --- a/src/features/cwd-lsp/ui/LspStatusView.svelte +++ b/src/features/cwd-lsp/ui/LspStatusView.svelte @@ -1,127 +1,129 @@
-
- - {#if loading} - Resolving… - {:else if hasLoaded && loadedCwd !== null} - {summary} - {:else} - Language servers - {/if} - - -
+
+ + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + Language servers + {/if} + + +
- {#if !canView} -

Open or start a conversation to see its language servers.

- {:else if error} -

{error}

- {:else if hasLoaded && loadedCwd === null} -

- Set a working directory in the Model panel to enable language servers. -

- {:else if hasLoaded && servers.length === 0 && !loading} -

No language servers configured for this directory.

- {:else} -
    - {#each servers as server (server.id)} -
  • -
    - {server.name} - - {#if server.busy} - - {/if} - {server.statusLabel} - -
    - {#if server.extensionsLabel} - {server.extensionsLabel} - {/if} - {server.root} - {#if server.error} - {server.error} - {/if} -
  • - {/each} -
- {/if} + {#if !canView} +

Open or start a conversation to see its language servers.

+ {:else if error} +

{error}

+ {:else if hasLoaded && loadedCwd === null} +

+ Set a working directory in the Model panel to enable language servers. +

+ {:else if hasLoaded && servers.length === 0 && !loading} +

No language servers configured for this directory.

+ {:else} +
    + {#each servers as server (server.id)} +
  • +
    + {server.name} + + {#if server.busy} + + {/if} + {server.statusLabel} + +
    + {#if server.extensionsLabel} + {server.extensionsLabel} + {/if} + {server.root} + {#if server.error} + {server.error} + {/if} +
  • + {/each} +
+ {/if}
diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index f5406b2..ff3aefa 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -3,6 +3,6 @@ export { default as Markdown } from "./ui/Markdown.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "markdown", - description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", + name: "markdown", + description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 7dbb878..54b0086 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest"; import { renderMarkdown } from "./markdown"; describe("renderMarkdown", () => { - it("renders GFM markdown (headings, emphasis)", () => { - const html = renderMarkdown("# Title\n\nSome **bold** text."); - expect(html).toContain("bold"); - }); - - it("highlights fenced code for a known language", () => { - const html = renderMarkdown("```javascript\nconst x = 1;\n```"); - expect(html).toContain("language-javascript"); - expect(html).toContain("hljs-keyword"); // `const` got highlighted - }); - - it("resolves language aliases (js -> javascript)", () => { - const html = renderMarkdown("```js\nconst x = 1;\n```"); - expect(html).toContain("hljs-keyword"); - }); - - it("escapes code for an unknown language without throwing", () => { - const html = renderMarkdown("```nope\nx\n```"); - expect(html).toContain("<b>"); - }); - - it("sanitizes dangerous HTML", () => { - const html = renderMarkdown("Hi there"); - expect(html).not.toContain(" there"); + expect(html).not.toContain("
- - {@html html} + + {@html html}
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index e34a4af..d65b3d1 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -3,38 +3,38 @@ import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; describe("Markdown", () => { - it("renders markdown into a .markdown-body container", () => { - const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); + it("renders markdown into a .markdown-body container", () => { + const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } }); - expect(container.querySelector(".markdown-body")).not.toBeNull(); - expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); - expect(container.querySelector("strong")?.textContent).toBe("hi"); - }); + expect(container.querySelector(".markdown-body")).not.toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.querySelector("strong")?.textContent).toBe("hi"); + }); - it("strips dangerous markup", () => { - const { container } = render(Markdown, { - props: { text: "before after" }, - }); + it("strips dangerous markup", () => { + const { container } = render(Markdown, { + props: { text: "before after" }, + }); - expect(container.querySelector("script")).toBeNull(); - expect(container.textContent).toContain("before"); - }); + expect(container.querySelector("script")).toBeNull(); + expect(container.textContent).toContain("before"); + }); - it("renders a copy button on a code block that copies the code to the clipboard", async () => { - const writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + it("renders a copy button on a code block that copies the code to the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - const { container } = render(Markdown, { - props: { text: "```js\nconst x = 1;\n```" }, - }); + const { container } = render(Markdown, { + props: { text: "```js\nconst x = 1;\n```" }, + }); - const button = container.querySelector("[data-copy]"); - expect(button).not.toBeNull(); - if (button === null) throw new Error("expected a copy button"); + const button = container.querySelector("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); - await fireEvent.click(button); + await fireEvent.click(button); - expect(writeText).toHaveBeenCalledTimes(1); - expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); - }); + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); + }); }); diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts index a161992..9abc023 100644 --- a/src/features/mcp/index.ts +++ b/src/features/mcp/index.ts @@ -3,6 +3,6 @@ export { default as McpStatusView } from "./ui/McpStatusView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "mcp", - description: "Per-conversation MCP (Model Context Protocol) server status", + name: "mcp", + description: "Per-conversation MCP (Model Context Protocol) server status", } as const; diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts index 23bf20f..d1a77d1 100644 --- a/src/features/mcp/logic/view-model.test.ts +++ b/src/features/mcp/logic/view-model.test.ts @@ -3,86 +3,86 @@ import { describe, expect, it } from "vitest"; import { summarizeMcpServers, viewMcpServer, viewMcpServers } from "./view-model"; const server = (over: Partial = {}): McpServerInfo => ({ - id: "freecad", - state: "connected", - toolCount: 12, - ...over, + id: "freecad", + state: "connected", + toolCount: 12, + ...over, }); describe("viewMcpServer", () => { - it("connected → success badge, not busy, no error, passes toolCount", () => { - const v = viewMcpServer(server({ toolCount: 5 })); - expect(v.badge).toBe("success"); - expect(v.statusLabel).toBe("Connected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - expect(v.toolCount).toBe(5); - expect(v.configSource).toBeNull(); - }); + it("connected → success badge, not busy, no error, passes toolCount", () => { + const v = viewMcpServer(server({ toolCount: 5 })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.toolCount).toBe(5); + expect(v.configSource).toBeNull(); + }); - it("connecting → warning badge + busy (spinner)", () => { - const v = viewMcpServer(server({ state: "connecting" })); - expect(v.badge).toBe("warning"); - expect(v.statusLabel).toBe("Connecting…"); - expect(v.busy).toBe(true); - expect(v.error).toBeNull(); - }); + it("connecting → warning badge + busy (spinner)", () => { + const v = viewMcpServer(server({ state: "connecting" })); + expect(v.badge).toBe("warning"); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); - it("disconnected → neutral badge, not busy", () => { - const v = viewMcpServer(server({ state: "disconnected" })); - expect(v.badge).toBe("neutral"); - expect(v.statusLabel).toBe("Disconnected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - }); + it("disconnected → neutral badge, not busy", () => { + const v = viewMcpServer(server({ state: "disconnected" })); + expect(v.badge).toBe("neutral"); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); - it("error → error badge + surfaces the reason (with a fallback)", () => { - const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" })); - expect(withReason.badge).toBe("error"); - expect(withReason.busy).toBe(false); - expect(withReason.error).toBe("ENOENT: npx"); + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT: npx"); - const noReason = viewMcpServer(server({ state: "error" })); - expect(noReason.error).toBe("Failed to connect"); - }); + const noReason = viewMcpServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to connect"); + }); - it("passes through configSource when present", () => { - const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" })); - expect(v.configSource).toBe(".dispatch/mcp.json"); - }); + it("passes through configSource when present", () => { + const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" })); + expect(v.configSource).toBe(".dispatch/mcp.json"); + }); - it("viewMcpServers maps a list preserving order", () => { - const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]); - expect(views.map((v) => v.id)).toEqual(["a", "b"]); - }); + it("viewMcpServers maps a list preserving order", () => { + const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); }); describe("summarizeMcpServers", () => { - it("empty list", () => { - expect(summarizeMcpServers([])).toBe("No MCP servers"); - }); + it("empty list", () => { + expect(summarizeMcpServers([])).toBe("No MCP servers"); + }); - it("counts connected / connecting / disconnected / errors", () => { - expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected"); - expect( - summarizeMcpServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "error" }), - ]), - ).toBe("1 connected, 1 error"); - expect( - summarizeMcpServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "connecting" }), - server({ id: "c", state: "disconnected" }), - server({ id: "d", state: "error" }), - server({ id: "e", state: "error" }), - ]), - ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors"); - }); + it("counts connected / connecting / disconnected / errors", () => { + expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "connecting" }), + server({ id: "c", state: "disconnected" }), + server({ id: "d", state: "error" }), + server({ id: "e", state: "error" }), + ]), + ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors"); + }); - it("lists only non-zero buckets", () => { - expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected"); - expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting"); - }); + it("lists only non-zero buckets", () => { + expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected"); + expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting"); + }); }); diff --git a/src/features/mcp/logic/view-model.ts b/src/features/mcp/logic/view-model.ts index 247f804..fdff78a 100644 --- a/src/features/mcp/logic/view-model.ts +++ b/src/features/mcp/logic/view-model.ts @@ -16,8 +16,8 @@ import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract /** Outcome of `GET /conversations/:id/mcp`; `null` when no real conversation is focused. */ export type McpStatusResult = - | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] } + | { readonly ok: false; readonly error: string }; export type LoadMcpStatus = () => Promise; @@ -26,18 +26,18 @@ export type LoadMcpStatus = () => Promise; export type Badge = "success" | "warning" | "error" | "neutral"; export interface McpServerView { - readonly id: string; - readonly state: McpServerState; - readonly statusLabel: string; - readonly badge: Badge; - /** True while the state is transient (show a spinner). */ - readonly busy: boolean; - /** The error reason when `state === "error"`, else null. */ - readonly error: string | null; - /** Number of tools discovered from this server. */ - readonly toolCount: number; - /** Which config source the server was resolved from, else null. */ - readonly configSource: string | null; + readonly id: string; + readonly state: McpServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source the server was resolved from, else null. */ + readonly configSource: string | null; } /** @@ -47,42 +47,42 @@ export interface McpServerView { * → error, and `disconnected` (a stable idle state) → neutral. */ export function viewMcpServer(server: McpServerInfo): McpServerView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (server.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "connecting": - statusLabel = "Connecting…"; - badge = "warning"; - busy = true; - break; - case "disconnected": - statusLabel = "Disconnected"; - badge = "neutral"; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - id: server.id, - state: server.state, - statusLabel, - badge, - busy, - error: server.state === "error" ? (server.error ?? "Failed to connect") : null, - toolCount: server.toolCount, - configSource: server.configSource ?? null, - }; + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to connect") : null, + toolCount: server.toolCount, + configSource: server.configSource ?? null, + }; } export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] { - return servers.map(viewMcpServer); + return servers.map(viewMcpServer); } /** @@ -90,21 +90,21 @@ export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpS * 1 error". Only non-zero buckets are listed. */ export function summarizeMcpServers(servers: readonly McpServerInfo[]): string { - if (servers.length === 0) return "No MCP servers"; - let connected = 0; - let connecting = 0; - let disconnected = 0; - let errored = 0; - for (const s of servers) { - if (s.state === "connected") connected++; - else if (s.state === "error") errored++; - else if (s.state === "connecting") connecting++; - else disconnected++; - } - const parts: string[] = []; - if (connected > 0) parts.push(`${connected} connected`); - if (connecting > 0) parts.push(`${connecting} connecting`); - if (disconnected > 0) parts.push(`${disconnected} disconnected`); - if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); - return parts.join(", "); + if (servers.length === 0) return "No MCP servers"; + let connected = 0; + let connecting = 0; + let disconnected = 0; + let errored = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else if (s.state === "connecting") connecting++; + else disconnected++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (connecting > 0) parts.push(`${connecting} connecting`); + if (disconnected > 0) parts.push(`${disconnected} disconnected`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); } diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte index 9ac40ba..ad51496 100644 --- a/src/features/mcp/ui/McpStatusView.svelte +++ b/src/features/mcp/ui/McpStatusView.svelte @@ -1,131 +1,133 @@
-
- - {#if loading} - Resolving… - {:else if hasLoaded && loadedCwd !== null} - {summary} - {:else} - MCP servers - {/if} - - -
+
+ + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + MCP servers + {/if} + + +
- {#if !canView} -

Open or start a conversation to see its MCP servers.

- {:else if error} -

{error}

- {:else if hasLoaded && loadedCwd === null} -

- Set a working directory in the Model panel to enable MCP servers. -

- {:else if hasLoaded && servers.length === 0 && !loading} -

No MCP servers configured for this directory.

- {:else} -
    - {#each servers as server (server.id)} -
  • -
    - {server.id} - - {#if server.busy} - - {/if} - {server.statusLabel} - -
    -
    - {#if server.configSource} - {server.configSource} - {:else} - - {/if} - {server.toolCount} tool{server.toolCount === 1 ? "" : "s"} -
    - {#if server.error} - {server.error} - {/if} -
  • - {/each} -
- {/if} + {#if !canView} +

Open or start a conversation to see its MCP servers.

+ {:else if error} +

{error}

+ {:else if hasLoaded && loadedCwd === null} +

+ Set a working directory in the Model panel to enable MCP servers. +

+ {:else if hasLoaded && servers.length === 0 && !loading} +

No MCP servers configured for this directory.

+ {:else} +
    + {#each servers as server (server.id)} +
  • +
    + {server.id} + + {#if server.busy} + + {/if} + {server.statusLabel} + +
    +
    + {#if server.configSource} + {server.configSource} + {:else} + + {/if} + {server.toolCount} tool{server.toolCount === 1 ? "" : "s"} +
    + {#if server.error} + {server.error} + {/if} +
  • + {/each} +
+ {/if}
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 69e7cd0..3942e97 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -4,6 +4,6 @@ export { default as ChatLimitField } from "./ui/ChatLimitField.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "settings", - description: "FE-local settings (chat limit)", + name: "settings", + description: "FE-local settings (chat limit)", } as const; diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts index 65fcae9..93c4786 100644 --- a/src/features/settings/logic/view-model.test.ts +++ b/src/features/settings/logic/view-model.test.ts @@ -3,59 +3,59 @@ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks"; import { chatLimitChanged, parseChatLimit } from "./view-model"; describe("parseChatLimit", () => { - it("parses a plain integer", () => { - expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 }); - expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 }); - }); - - it("trims surrounding whitespace", () => { - expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 }); - }); - - it("floors a decimal", () => { - expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 }); - }); - - it("clamps below the floor to MIN_CHAT_LIMIT", () => { - expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); - expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); - expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); - }); - - it("clamps above the ceiling to MAX_CHAT_LIMIT", () => { - expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT }); - }); - - it("rejects an empty string", () => { - expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) }); - expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) }); - }); - - it("rejects non-numeric input", () => { - expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) }); - expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) }); - }); + it("parses a plain integer", () => { + expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 }); + expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 }); + }); + + it("trims surrounding whitespace", () => { + expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 }); + }); + + it("floors a decimal", () => { + expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 }); + }); + + it("clamps below the floor to MIN_CHAT_LIMIT", () => { + expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT }); + }); + + it("clamps above the ceiling to MAX_CHAT_LIMIT", () => { + expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT }); + }); + + it("rejects an empty string", () => { + expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) }); + expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) }); + }); + + it("rejects non-numeric input", () => { + expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) }); + expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) }); + }); }); describe("chatLimitChanged", () => { - it("is false when the typed value normalizes to the current limit", () => { - expect(chatLimitChanged("256", 256)).toBe(false); - }); - - it("is true when the typed value differs", () => { - expect(chatLimitChanged("100", 256)).toBe(true); - expect(chatLimitChanged("256", 100)).toBe(true); - }); - - it("is false for empty / invalid input (nothing submittable)", () => { - expect(chatLimitChanged("", 256)).toBe(false); - expect(chatLimitChanged("abc", 256)).toBe(false); - }); - - it("is false when the typed value clamps to the current limit", () => { - // 5 clamps to MIN (10); current is already 10 → no change. - expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false); - // A huge value clamps to MAX; current is already MAX → no change. - expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false); - }); + it("is false when the typed value normalizes to the current limit", () => { + expect(chatLimitChanged("256", 256)).toBe(false); + }); + + it("is true when the typed value differs", () => { + expect(chatLimitChanged("100", 256)).toBe(true); + expect(chatLimitChanged("256", 100)).toBe(true); + }); + + it("is false for empty / invalid input (nothing submittable)", () => { + expect(chatLimitChanged("", 256)).toBe(false); + expect(chatLimitChanged("abc", 256)).toBe(false); + }); + + it("is false when the typed value clamps to the current limit", () => { + // 5 clamps to MIN (10); current is already 10 → no change. + expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false); + // A huge value clamps to MAX; current is already MAX → no change. + expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false); + }); }); diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts index caf76ee..73f6d17 100644 --- a/src/features/settings/logic/view-model.ts +++ b/src/features/settings/logic/view-model.ts @@ -17,8 +17,8 @@ import { normalizeChatLimit } from "../../../core/chunks"; /** Outcome of persisting a chat-limit setting. */ export type ChatLimitSaveResult = - | { readonly ok: true; readonly chatLimit: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly chatLimit: number } + | { readonly ok: false; readonly error: string }; export type SaveChatLimit = (value: number) => Promise; @@ -26,8 +26,8 @@ export type SaveChatLimit = (value: number) => Promise; /** Result of parsing a typed chat-limit string. */ export type ChatLimitParse = - | { readonly ok: true; readonly value: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly value: number } + | { readonly ok: false; readonly error: string }; /** * Parse a typed chat-limit string into a normalized limit (floored + clamped to @@ -37,15 +37,15 @@ export type ChatLimitParse = * from user typing. */ export function parseChatLimit(raw: string): ChatLimitParse { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return { ok: false, error: "Enter a number." }; - } - const n = Number(trimmed); - if (!Number.isFinite(n)) { - return { ok: false, error: "Must be a number." }; - } - return { ok: true, value: normalizeChatLimit(n) }; + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return { ok: false, error: "Enter a number." }; + } + const n = Number(trimmed); + if (!Number.isFinite(n)) { + return { ok: false, error: "Must be a number." }; + } + return { ok: true, value: normalizeChatLimit(n) }; } /** @@ -54,7 +54,7 @@ export function parseChatLimit(raw: string): ChatLimitParse { * dirty-check for the input — it must NOT mutate or clamp, only compare. */ export function chatLimitChanged(typed: string, current: number): boolean { - const parsed = parseChatLimit(typed); - if (!parsed.ok) return false; - return parsed.value !== current; + const parsed = parseChatLimit(typed); + if (!parsed.ok) return false; + return parsed.value !== current; } diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte index 9b9911a..502212f 100644 --- a/src/features/settings/ui/ChatLimitField.svelte +++ b/src/features/settings/ui/ChatLimitField.svelte @@ -1,104 +1,104 @@
- Chat limit -
- { - if (e.key === "Enter") handleSave(); - }} - aria-label="Chat limit" - /> - -
- {#if error} -

{error}

- {:else if justSaved && !dirty} -

Saved{savedValue !== null ? `: ${savedValue}.` : "."}

- {:else} -

- Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads - older history; raise it and use "Show earlier" to page back in. -

- {/if} + Chat limit +
+ { + if (e.key === "Enter") handleSave(); + }} + aria-label="Chat limit" + /> + +
+ {#if error} +

{error}

+ {:else if justSaved && !dirty} +

Saved{savedValue !== null ? `: ${savedValue}.` : "."}

+ {:else} +

+ Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads + older history; raise it and use "Show earlier" to page back in. +

+ {/if}
diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts index 73bc449..8f8da25 100644 --- a/src/features/settings/ui/ChatLimitField.test.ts +++ b/src/features/settings/ui/ChatLimitField.test.ts @@ -6,84 +6,84 @@ import ChatLimitField from "./ChatLimitField.svelte"; // A fake save that resolves ok with the value it was given. function fakeSave(): { - saves: number[]; - last: number | null; - impl: (value: number) => Promise; + saves: number[]; + last: number | null; + impl: (value: number) => Promise; } { - const saves: number[] = []; - return { - saves, - last: null, - impl: async (value: number) => { - saves.push(value); - return { ok: true, chatLimit: value }; - }, - }; + const saves: number[] = []; + return { + saves, + last: null, + impl: async (value: number) => { + saves.push(value); + return { ok: true, chatLimit: value }; + }, + }; } describe("ChatLimitField", () => { - it("seeds the input from the persisted limit and disables Set while unchanged", () => { - render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } }); - expect(screen.getByLabelText("Chat limit")).toHaveValue("256"); - expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); - }); + it("seeds the input from the persisted limit and disables Set while unchanged", () => { + render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } }); + expect(screen.getByLabelText("Chat limit")).toHaveValue("256"); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); - it("enables Set when the typed value differs (regression: number-binding coercion)", async () => { - const user = userEvent.setup(); - const save = fakeSave(); - render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + it("enables Set when the typed value differs (regression: number-binding coercion)", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); - const input = screen.getByLabelText("Chat limit"); - await user.clear(input); - await user.type(input, "10"); + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "10"); - expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); - }); + expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); + }); - it("keeps Set disabled for non-numeric input", async () => { - const user = userEvent.setup(); - const save = fakeSave(); - render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + it("keeps Set disabled for non-numeric input", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); - const input = screen.getByLabelText("Chat limit"); - await user.clear(input); - await user.type(input, "abc"); + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "abc"); - expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); - }); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + }); - it("clicking Set calls save with the parsed value and shows the confirmation", async () => { - const user = userEvent.setup(); - const save = fakeSave(); - const { rerender } = render(ChatLimitField, { - props: { chatLimit: 256, save: save.impl }, - }); + it("clicking Set calls save with the parsed value and shows the confirmation", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + const { rerender } = render(ChatLimitField, { + props: { chatLimit: 256, save: save.impl }, + }); - const input = screen.getByLabelText("Chat limit"); - await user.clear(input); - await user.type(input, "50"); - await user.click(screen.getByRole("button", { name: "Set" })); + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "50"); + await user.click(screen.getByRole("button", { name: "Set" })); - expect(save.saves).toEqual([50]); - // In the real app the reactive `chatLimit` prop updates to the saved value - // (the store sets it synchronously), which clears `dirty` and reveals the - // "Saved" badge. Rerender simulates that propagation. - rerender({ chatLimit: 50, save: save.impl }); - expect(screen.getByText(/Saved/i)).toBeInTheDocument(); - }); + expect(save.saves).toEqual([50]); + // In the real app the reactive `chatLimit` prop updates to the saved value + // (the store sets it synchronously), which clears `dirty` and reveals the + // "Saved" badge. Rerender simulates that propagation. + rerender({ chatLimit: 50, save: save.impl }); + expect(screen.getByText(/Saved/i)).toBeInTheDocument(); + }); - it("clamps a below-floor value when saving", async () => { - const user = userEvent.setup(); - const save = fakeSave(); - render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); + it("clamps a below-floor value when saving", async () => { + const user = userEvent.setup(); + const save = fakeSave(); + render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } }); - const input = screen.getByLabelText("Chat limit"); - await user.clear(input); - await user.type(input, "5"); // clamps to MIN (10) - await user.click(screen.getByRole("button", { name: "Set" })); + const input = screen.getByLabelText("Chat limit"); + await user.clear(input); + await user.type(input, "5"); // clamps to MIN (10) + await user.click(screen.getByRole("button", { name: "Set" })); - // The save port receives the clamped value (the view-model clamps before save). - expect(save.saves).toEqual([10]); - expect(input).toHaveValue("10"); - }); + // The save port receives the clamped value (the view-model clamps before save). + expect(save.saves).toEqual([10]); + expect(input).toHaveValue("10"); + }); }); diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts index 0d30257..73b11d6 100644 --- a/src/features/smart-scroll/index.ts +++ b/src/features/smart-scroll/index.ts @@ -1,17 +1,17 @@ export type { - ScrollCommand, - ScrollGeometry, - SmartScrollResult, - SmartScrollState, + ScrollCommand, + ScrollGeometry, + SmartScrollResult, + SmartScrollState, } from "./logic/smart-scroll"; export { - createSmartScrollState, - isNearBottom, - NEAR_BOTTOM_THRESHOLD, - onContentChange, - onReset, - onResume, - onScroll, + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, } from "./logic/smart-scroll"; export type { SmartScrollController } from "./ui/controller.svelte"; export { createSmartScrollController } from "./ui/controller.svelte"; @@ -19,7 +19,7 @@ export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "smart-scroll", - description: - "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", + name: "smart-scroll", + description: + "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up", } as const; diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts index fc3e3d1..94ba1d7 100644 --- a/src/features/smart-scroll/logic/smart-scroll.test.ts +++ b/src/features/smart-scroll/logic/smart-scroll.test.ts @@ -1,103 +1,103 @@ import { describe, expect, it } from "vitest"; import { - createSmartScrollState, - isNearBottom, - NEAR_BOTTOM_THRESHOLD, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollGeometry, + createSmartScrollState, + isNearBottom, + NEAR_BOTTOM_THRESHOLD, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollGeometry, } from "./smart-scroll"; // A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom. const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 }; const nearBottom: ScrollGeometry = { - scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, - scrollHeight: 1000, - clientHeight: 100, + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD, + scrollHeight: 1000, + clientHeight: 100, }; const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 }; describe("isNearBottom", () => { - it("is true exactly at the bottom", () => { - expect(isNearBottom(atBottom)).toBe(true); - }); + it("is true exactly at the bottom", () => { + expect(isNearBottom(atBottom)).toBe(true); + }); - it("is true within the threshold of the bottom", () => { - expect(isNearBottom(nearBottom)).toBe(true); - }); + it("is true within the threshold of the bottom", () => { + expect(isNearBottom(nearBottom)).toBe(true); + }); - it("is false just beyond the threshold", () => { - expect( - isNearBottom({ - scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, - scrollHeight: 1000, - clientHeight: 100, - }), - ).toBe(false); - }); + it("is false just beyond the threshold", () => { + expect( + isNearBottom({ + scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1, + scrollHeight: 1000, + clientHeight: 100, + }), + ).toBe(false); + }); - it("is false when scrolled well up", () => { - expect(isNearBottom(scrolledUp)).toBe(false); - }); + it("is false when scrolled well up", () => { + expect(isNearBottom(scrolledUp)).toBe(false); + }); - it("honours a custom threshold", () => { - const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; - expect(isNearBottom(geom, 50)).toBe(false); - expect(isNearBottom(geom, 150)).toBe(true); - }); + it("honours a custom threshold", () => { + const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 }; + expect(isNearBottom(geom, 50)).toBe(false); + expect(isNearBottom(geom, 150)).toBe(true); + }); }); describe("smart-scroll reducer", () => { - it("starts stuck and hides the button", () => { - const s = createSmartScrollState(); - expect(s.stuck).toBe(true); - }); + it("starts stuck and hides the button", () => { + const s = createSmartScrollState(); + expect(s.stuck).toBe(true); + }); - it("onScroll up unsticks and shows the button, with no command", () => { - const r = onScroll(createSmartScrollState(), scrolledUp); - expect(r.state.stuck).toBe(false); - expect(r.showButton).toBe(true); - expect(r.command).toBeNull(); - }); + it("onScroll up unsticks and shows the button, with no command", () => { + const r = onScroll(createSmartScrollState(), scrolledUp); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + expect(r.command).toBeNull(); + }); - it("onScroll back to the bottom re-sticks and hides the button", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onScroll(up, atBottom); - expect(r.state.stuck).toBe(true); - expect(r.showButton).toBe(false); - expect(r.command).toBeNull(); - }); + it("onScroll back to the bottom re-sticks and hides the button", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onScroll(up, atBottom); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toBeNull(); + }); - it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { - const r = onContentChange(createSmartScrollState(), atBottom); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); - expect(r.state.stuck).toBe(true); - }); + it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => { + const r = onContentChange(createSmartScrollState(), atBottom); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.state.stuck).toBe(true); + }); - it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onContentChange(up, scrolledUp); - expect(r.command).toBeNull(); - expect(r.state.stuck).toBe(false); - expect(r.showButton).toBe(true); - }); + it("onContentChange while unstuck emits NO command (leave the reader in place)", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onContentChange(up, scrolledUp); + expect(r.command).toBeNull(); + expect(r.state.stuck).toBe(false); + expect(r.showButton).toBe(true); + }); - it("onResume re-sticks and emits an ANIMATED scroll", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onResume(up); - expect(r.state.stuck).toBe(true); - expect(r.showButton).toBe(false); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); - }); + it("onResume re-sticks and emits an ANIMATED scroll", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onResume(up); + expect(r.state.stuck).toBe(true); + expect(r.showButton).toBe(false); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true }); + }); - it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { - const up = onScroll(createSmartScrollState(), scrolledUp).state; - const r = onReset(); - void up; - expect(r.state.stuck).toBe(true); - expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); - expect(r.showButton).toBe(false); - }); + it("onReset returns to stuck and snaps (non-animated) to the bottom", () => { + const up = onScroll(createSmartScrollState(), scrolledUp).state; + const r = onReset(); + void up; + expect(r.state.stuck).toBe(true); + expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false }); + expect(r.showButton).toBe(false); + }); }); diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts index 021b3fe..4a04812 100644 --- a/src/features/smart-scroll/logic/smart-scroll.ts +++ b/src/features/smart-scroll/logic/smart-scroll.ts @@ -6,12 +6,12 @@ /** A snapshot of a scroll container's vertical geometry (in CSS pixels). */ export interface ScrollGeometry { - /** Current scroll offset from the top. */ - readonly scrollTop: number; - /** Total scrollable content height. */ - readonly scrollHeight: number; - /** Visible viewport height. */ - readonly clientHeight: number; + /** Current scroll offset from the top. */ + readonly scrollTop: number; + /** Total scrollable content height. */ + readonly scrollHeight: number; + /** Visible viewport height. */ + readonly clientHeight: number; } /** Distance (px) from the bottom within which we still consider the view "at bottom". */ @@ -19,43 +19,43 @@ export const NEAR_BOTTOM_THRESHOLD = 64; /** True when the viewport is within `threshold` px of the content's bottom edge. */ export function isNearBottom( - geom: ScrollGeometry, - threshold: number = NEAR_BOTTOM_THRESHOLD, + geom: ScrollGeometry, + threshold: number = NEAR_BOTTOM_THRESHOLD, ): boolean { - return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; + return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold; } /** A scroll the shell should perform on the real element. */ export interface ScrollCommand { - readonly kind: "scroll-to-bottom"; - /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ - readonly animate: boolean; + readonly kind: "scroll-to-bottom"; + /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */ + readonly animate: boolean; } export interface SmartScrollState { - /** - * Whether the view is currently following the bottom. While `stuck`, new - * content keeps the view pinned to the bottom; once the user scrolls up it - * goes false and stays false until they return to the bottom (or resume). - */ - readonly stuck: boolean; + /** + * Whether the view is currently following the bottom. While `stuck`, new + * content keeps the view pinned to the bottom; once the user scrolls up it + * goes false and stays false until they return to the bottom (or resume). + */ + readonly stuck: boolean; } /** A reducer step's result: the next state, an optional command, and whether to show the button. */ export interface SmartScrollResult { - readonly state: SmartScrollState; - readonly command: ScrollCommand | null; - /** Show the "scroll to bottom" affordance exactly when not stuck. */ - readonly showButton: boolean; + readonly state: SmartScrollState; + readonly command: ScrollCommand | null; + /** Show the "scroll to bottom" affordance exactly when not stuck. */ + readonly showButton: boolean; } /** Initial state — start stuck so the first content snaps to the bottom. */ export function createSmartScrollState(): SmartScrollState { - return { stuck: true }; + return { stuck: true }; } function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult { - return { state, command, showButton: !state.stuck }; + return { state, command, showButton: !state.stuck }; } /** @@ -64,7 +64,7 @@ function result(state: SmartScrollState, command: ScrollCommand | null): SmartSc * command — reacting to the user's own scroll with a scroll would fight them. */ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult { - return result({ stuck: isNearBottom(geom) }, null); + return result({ stuck: isNearBottom(geom) }, null); } /** @@ -73,7 +73,7 @@ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartS * they are. State is unchanged — content growth alone never flips `stuck`. */ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult { - return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); + return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null); } /** @@ -81,7 +81,7 @@ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): * emit an animated scroll. */ export function onResume(_state: SmartScrollState): SmartScrollResult { - return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); + return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true }); } /** @@ -89,5 +89,5 @@ export function onResume(_state: SmartScrollState): SmartScrollResult { * Reset to stuck and snap (non-animated) to the bottom of the new content. */ export function onReset(): SmartScrollResult { - return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); + return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false }); } diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte index 6fbd326..38ec1f5 100644 --- a/src/features/smart-scroll/ui/ScrollToBottom.svelte +++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte @@ -1,36 +1,36 @@ diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts index dbe65d1..fee6561 100644 --- a/src/features/smart-scroll/ui/controller.svelte.ts +++ b/src/features/smart-scroll/ui/controller.svelte.ts @@ -7,134 +7,134 @@ // it on unmount. import { - createSmartScrollState, - onContentChange, - onReset, - onResume, - onScroll, - type ScrollCommand, - type ScrollGeometry, - type SmartScrollResult, - type SmartScrollState, + createSmartScrollState, + onContentChange, + onReset, + onResume, + onScroll, + type ScrollCommand, + type ScrollGeometry, + type SmartScrollResult, + type SmartScrollState, } from "../logic/smart-scroll"; export interface SmartScrollController { - /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ - readonly showButton: boolean; - /** - * Non-reactive point-in-time query: is the view stuck to the bottom right now? - * For imperative callers (e.g. the chat-limit unload gate) that poll at event - * time rather than subscribing — reads the reducer state, not a rune. - */ - isAtBottom(): boolean; - /** - * Attach to the scroll container; returns a teardown to call on unmount. - * Pass the inner CONTENT element to also follow height changes that aren't a - * transcript update (async markdown/highlight, image loads, a collapse toggling, - * viewport reflow) via a ResizeObserver. - */ - attach(el: HTMLElement, content?: HTMLElement): () => void; - /** - * Notify that the transcript content changed (a streamed delta / new message). - * While stuck, keeps the view pinned to the bottom. - */ - contentChanged(): void; - /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ - reset(): void; - /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ - resume(): void; + /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */ + readonly showButton: boolean; + /** + * Non-reactive point-in-time query: is the view stuck to the bottom right now? + * For imperative callers (e.g. the chat-limit unload gate) that poll at event + * time rather than subscribing — reads the reducer state, not a rune. + */ + isAtBottom(): boolean; + /** + * Attach to the scroll container; returns a teardown to call on unmount. + * Pass the inner CONTENT element to also follow height changes that aren't a + * transcript update (async markdown/highlight, image loads, a collapse toggling, + * viewport reflow) via a ResizeObserver. + */ + attach(el: HTMLElement, content?: HTMLElement): () => void; + /** + * Notify that the transcript content changed (a streamed delta / new message). + * While stuck, keeps the view pinned to the bottom. + */ + contentChanged(): void; + /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */ + reset(): void; + /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */ + resume(): void; } function geometryOf(el: HTMLElement): ScrollGeometry { - return { - scrollTop: el.scrollTop, - scrollHeight: el.scrollHeight, - clientHeight: el.clientHeight, - }; + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + }; } export function createSmartScrollController(): SmartScrollController { - let state: SmartScrollState = createSmartScrollState(); - let showButton = $state(false); - let el: HTMLElement | null = null; - // True while WE drive a programmatic scroll, so the resulting `scroll` event - // doesn't get misread as the user scrolling up. Cleared on `scrollend`. - let selfScrolling = false; - - function run(command: ScrollCommand | null): void { - if (!command || !el) return; - selfScrolling = true; - el.scrollTo({ - top: el.scrollHeight, - behavior: command.animate ? "smooth" : "instant", - }); - } - - function apply(r: SmartScrollResult): void { - state = r.state; - showButton = r.showButton; - run(r.command); - } - - function handleScroll(): void { - if (!el || selfScrolling) return; - apply(onScroll(state, geometryOf(el))); - } - - function handleScrollEnd(): void { - selfScrolling = false; - } - - return { - get showButton(): boolean { - return showButton; - }, - - isAtBottom(): boolean { - return state.stuck; - }, - - attach(node: HTMLElement, content?: HTMLElement): () => void { - el = node; - node.addEventListener("scroll", handleScroll, { passive: true }); - node.addEventListener("scrollend", handleScrollEnd); - - // A ResizeObserver keeps the view pinned through height changes that are - // NOT a transcript update — async markdown/syntax-highlight, image loads, a - // collapse toggling, font swaps, viewport reflow — which a content-count - // signal can't see. Observe the CONTENT (it grows) and the container (it - // changes on viewport resize). Routed through `onContentChange`, so it only - // scrolls while stuck and never fights the reader. The `selfScrolling` guard - // (and the fact that scrolling doesn't resize content) prevents any loop. - let ro: ResizeObserver | null = null; - if (typeof ResizeObserver !== "undefined") { - ro = new ResizeObserver(() => { - if (!el || selfScrolling) return; - apply(onContentChange(state, geometryOf(el))); - }); - if (content) ro.observe(content); - ro.observe(node); - } - - return () => { - node.removeEventListener("scroll", handleScroll); - node.removeEventListener("scrollend", handleScrollEnd); - ro?.disconnect(); - if (el === node) el = null; - }; - }, - - contentChanged(): void { - if (!el) return; - apply(onContentChange(state, geometryOf(el))); - }, - - reset(): void { - apply(onReset()); - }, - - resume(): void { - apply(onResume(state)); - }, - }; + let state: SmartScrollState = createSmartScrollState(); + let showButton = $state(false); + let el: HTMLElement | null = null; + // True while WE drive a programmatic scroll, so the resulting `scroll` event + // doesn't get misread as the user scrolling up. Cleared on `scrollend`. + let selfScrolling = false; + + function run(command: ScrollCommand | null): void { + if (!command || !el) return; + selfScrolling = true; + el.scrollTo({ + top: el.scrollHeight, + behavior: command.animate ? "smooth" : "instant", + }); + } + + function apply(r: SmartScrollResult): void { + state = r.state; + showButton = r.showButton; + run(r.command); + } + + function handleScroll(): void { + if (!el || selfScrolling) return; + apply(onScroll(state, geometryOf(el))); + } + + function handleScrollEnd(): void { + selfScrolling = false; + } + + return { + get showButton(): boolean { + return showButton; + }, + + isAtBottom(): boolean { + return state.stuck; + }, + + attach(node: HTMLElement, content?: HTMLElement): () => void { + el = node; + node.addEventListener("scroll", handleScroll, { passive: true }); + node.addEventListener("scrollend", handleScrollEnd); + + // A ResizeObserver keeps the view pinned through height changes that are + // NOT a transcript update — async markdown/syntax-highlight, image loads, a + // collapse toggling, font swaps, viewport reflow — which a content-count + // signal can't see. Observe the CONTENT (it grows) and the container (it + // changes on viewport resize). Routed through `onContentChange`, so it only + // scrolls while stuck and never fights the reader. The `selfScrolling` guard + // (and the fact that scrolling doesn't resize content) prevents any loop. + let ro: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro = new ResizeObserver(() => { + if (!el || selfScrolling) return; + apply(onContentChange(state, geometryOf(el))); + }); + if (content) ro.observe(content); + ro.observe(node); + } + + return () => { + node.removeEventListener("scroll", handleScroll); + node.removeEventListener("scrollend", handleScrollEnd); + ro?.disconnect(); + if (el === node) el = null; + }; + }, + + contentChanged(): void { + if (!el) return; + apply(onContentChange(state, geometryOf(el))); + }, + + reset(): void { + apply(onReset()); + }, + + resume(): void { + apply(onResume(state)); + }, + }; } diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts index 614f4b0..906cc91 100644 --- a/src/features/smart-scroll/ui/controller.test.ts +++ b/src/features/smart-scroll/ui/controller.test.ts @@ -5,168 +5,168 @@ import { createSmartScrollController } from "./controller.svelte"; // geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend". // Faking this outermost edge is the sanctioned mock (no internal modules mocked). function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) { - const listeners = new Map>(); - const el = { - scrollTop: 0, - scrollHeight: opts?.scrollHeight ?? 1000, - clientHeight: opts?.clientHeight ?? 100, - scrollTo: vi.fn((arg: ScrollToOptions) => { - // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. - el.scrollTop = (arg.top ?? 0) - 0; - if (arg.behavior !== "smooth") { - fire("scroll"); - fire("scrollend"); - } - }), - addEventListener: (type: string, fn: EventListener) => { - if (!listeners.has(type)) listeners.set(type, new Set()); - listeners.get(type)?.add(fn); - }, - removeEventListener: (type: string, fn: EventListener) => { - listeners.get(type)?.delete(fn); - }, - }; - function fire(type: string): void { - for (const fn of listeners.get(type) ?? []) fn(new Event(type)); - } - // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). - function userScrollTo(top: number): void { - el.scrollTop = top; - fire("scroll"); - } - return { - el: el as unknown as HTMLElement, - scrollTo: el.scrollTo, - fire, - userScrollTo, - listenerCount: () => listeners, - }; + const listeners = new Map>(); + const el = { + scrollTop: 0, + scrollHeight: opts?.scrollHeight ?? 1000, + clientHeight: opts?.clientHeight ?? 100, + scrollTo: vi.fn((arg: ScrollToOptions) => { + // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend. + el.scrollTop = (arg.top ?? 0) - 0; + if (arg.behavior !== "smooth") { + fire("scroll"); + fire("scrollend"); + } + }), + addEventListener: (type: string, fn: EventListener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)?.add(fn); + }, + removeEventListener: (type: string, fn: EventListener) => { + listeners.get(type)?.delete(fn); + }, + }; + function fire(type: string): void { + for (const fn of listeners.get(type) ?? []) fn(new Event(type)); + } + // Simulate the USER scrolling to a given offset (fires scroll, not self-driven). + function userScrollTo(top: number): void { + el.scrollTop = top; + fire("scroll"); + } + return { + el: el as unknown as HTMLElement, + scrollTo: el.scrollTo, + fire, + userScrollTo, + listenerCount: () => listeners, + }; } describe("smart-scroll controller", () => { - it("starts with the button hidden", () => { - const c = createSmartScrollController(); - expect(c.showButton).toBe(false); - }); + it("starts with the button hidden", () => { + const c = createSmartScrollController(); + expect(c.showButton).toBe(false); + }); - it("contentChanged while stuck scrolls to the bottom instantly", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - c.contentChanged(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "instant", - }); - expect(c.showButton).toBe(false); - }); + it("contentChanged while stuck scrolls to the bottom instantly", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + c.contentChanged(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); - it("a user scroll up shows the button and stops auto-following", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); // far from the bottom - expect(c.showButton).toBe(true); + it("a user scroll up shows the button and stops auto-following", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); // far from the bottom + expect(c.showButton).toBe(true); - const scrollTo = fake.scrollTo; - scrollTo.mockClear(); - c.contentChanged(); // streaming more content... - expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down - expect(c.showButton).toBe(true); - }); + const scrollTo = fake.scrollTo; + scrollTo.mockClear(); + c.contentChanged(); // streaming more content... + expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down + expect(c.showButton).toBe(true); + }); - it("self-driven scrolls are not misread as the user scrolling up", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - // contentChanged drives an instant scrollTo, whose synthetic scroll event - // must NOT flip us to unstuck (selfScrolling guard). - c.contentChanged(); - expect(c.showButton).toBe(false); - }); + it("self-driven scrolls are not misread as the user scrolling up", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + // contentChanged drives an instant scrollTo, whose synthetic scroll event + // must NOT flip us to unstuck (selfScrolling guard). + c.contentChanged(); + expect(c.showButton).toBe(false); + }); - it("resume re-sticks and smooth-scrolls to the bottom", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); - expect(c.showButton).toBe(true); + it("resume re-sticks and smooth-scrolls to the bottom", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); - c.resume(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "smooth", - }); - expect(c.showButton).toBe(false); - }); + c.resume(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "smooth", + }); + expect(c.showButton).toBe(false); + }); - it("reset snaps to the bottom and hides the button", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - c.attach(fake.el); - fake.userScrollTo(200); - expect(c.showButton).toBe(true); - c.reset(); - expect(fake.scrollTo).toHaveBeenCalledWith({ - top: 1000, - behavior: "instant", - }); - expect(c.showButton).toBe(false); - }); + it("reset snaps to the bottom and hides the button", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + c.attach(fake.el); + fake.userScrollTo(200); + expect(c.showButton).toBe(true); + c.reset(); + expect(fake.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + expect(c.showButton).toBe(false); + }); - it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { - const holder: { cb: ResizeObserverCallback | null } = { cb: null }; - const observed: unknown[] = []; - const disconnect = vi.fn(); - class FakeResizeObserver { - constructor(cb: ResizeObserverCallback) { - holder.cb = cb; - } - observe(target: Element): void { - observed.push(target); - } - unobserve(): void {} - disconnect = disconnect; - } - vi.stubGlobal("ResizeObserver", FakeResizeObserver); - try { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - const content = { id: "content" } as unknown as HTMLElement; - const teardown = c.attach(fake.el, content); + it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => { + const holder: { cb: ResizeObserverCallback | null } = { cb: null }; + const observed: unknown[] = []; + const disconnect = vi.fn(); + class FakeResizeObserver { + constructor(cb: ResizeObserverCallback) { + holder.cb = cb; + } + observe(target: Element): void { + observed.push(target); + } + unobserve(): void {} + disconnect = disconnect; + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + try { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const content = { id: "content" } as unknown as HTMLElement; + const teardown = c.attach(fake.el, content); - // Observes both the content (it grows) and the scroll container (viewport resize). - expect(observed).toContain(content); - expect(observed).toContain(fake.el); + // Observes both the content (it grows) and the scroll container (viewport resize). + expect(observed).toContain(content); + expect(observed).toContain(fake.el); - // Stuck → a resize keeps us pinned to the bottom. - fake.scrollTo.mockClear(); - holder.cb?.([], {} as ResizeObserver); - expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); + // Stuck → a resize keeps us pinned to the bottom. + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" }); - // Reader scrolls up → a later resize must NOT yank them down. - fake.userScrollTo(200); - fake.scrollTo.mockClear(); - holder.cb?.([], {} as ResizeObserver); - expect(fake.scrollTo).not.toHaveBeenCalled(); + // Reader scrolls up → a later resize must NOT yank them down. + fake.userScrollTo(200); + fake.scrollTo.mockClear(); + holder.cb?.([], {} as ResizeObserver); + expect(fake.scrollTo).not.toHaveBeenCalled(); - // Teardown disconnects the observer. - teardown(); - expect(disconnect).toHaveBeenCalled(); - } finally { - vi.unstubAllGlobals(); - } - }); + // Teardown disconnects the observer. + teardown(); + expect(disconnect).toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); - it("attach returns a teardown that removes both listeners", () => { - const c = createSmartScrollController(); - const fake = createFakeScrollEl(); - const teardown = c.attach(fake.el); - const before = fake.listenerCount(); - expect(before.get("scroll")?.size).toBe(1); - expect(before.get("scrollend")?.size).toBe(1); - teardown(); - expect(before.get("scroll")?.size).toBe(0); - expect(before.get("scrollend")?.size).toBe(0); - }); + it("attach returns a teardown that removes both listeners", () => { + const c = createSmartScrollController(); + const fake = createFakeScrollEl(); + const teardown = c.attach(fake.el); + const before = fake.listenerCount(); + expect(before.get("scroll")?.size).toBe(1); + expect(before.get("scrollend")?.size).toBe(1); + teardown(); + expect(before.get("scroll")?.size).toBe(0); + expect(before.get("scrollend")?.size).toBe(0); + }); }); diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts index 8f289f1..1e21e3d 100644 --- a/src/features/surface-host/index.ts +++ b/src/features/surface-host/index.ts @@ -4,6 +4,6 @@ export { default as SurfaceView } from "./ui/SurfaceView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "surface-host", - description: "Generic renderer for backend-declared surfaces", + name: "surface-host", + description: "Generic renderer for backend-declared surfaces", } as const; diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts index ce078d9..8d55eb7 100644 --- a/src/features/surface-host/logic/message-queue.test.ts +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -3,46 +3,46 @@ import { describe, expect, it } from "vitest"; import { parseMessageQueuePayload } from "./message-queue"; const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ - id, - text, - queuedAt, + id, + text, + queuedAt, }); describe("parseMessageQueuePayload", () => { - it("parses a well-formed payload with messages", () => { - const data = parseMessageQueuePayload({ - messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], - }); - expect(data).toEqual({ - messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], - }); - }); + it("parses a well-formed payload with messages", () => { + const data = parseMessageQueuePayload({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + expect(data).toEqual({ + messages: [msg("m1", "steer left"), msg("m2", "actually, go right")], + }); + }); - it("parses an empty-messages payload (queue is empty)", () => { - expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] }); - }); + it("parses an empty-messages payload (queue is empty)", () => { + expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] }); + }); - it("preserves message order", () => { - const data = parseMessageQueuePayload({ - messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")], - }); - expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]); - }); + it("preserves message order", () => { + const data = parseMessageQueuePayload({ + messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")], + }); + expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]); + }); - it.each([ - ["null", null], - ["a number", 7], - ["a string", "nope"], - ["missing messages key", { foo: [] }], - ["messages not an array", { messages: "x" }], - ["entry not an object", { messages: ["x"] }], - ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }], - ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }], - ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }], - ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }], - ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }], - ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }], - ])("returns null for invalid payload: %s", (_label, payload) => { - expect(parseMessageQueuePayload(payload)).toBeNull(); - }); + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing messages key", { foo: [] }], + ["messages not an array", { messages: "x" }], + ["entry not an object", { messages: ["x"] }], + ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }], + ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }], + ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }], + ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }], + ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }], + ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseMessageQueuePayload(payload)).toBeNull(); + }); }); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts index a8e1567..79707a5 100644 --- a/src/features/surface-host/logic/message-queue.ts +++ b/src/features/surface-host/logic/message-queue.ts @@ -14,31 +14,31 @@ import type { QueuedMessage } from "@dispatch/wire"; * payload shape. */ export interface MessageQueueData { - readonly messages: readonly QueuedMessage[]; + readonly messages: readonly QueuedMessage[]; } function isQueuedMessage(v: unknown): v is QueuedMessage { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - return ( - typeof o.id === "string" && - typeof o.text === "string" && - typeof o.queuedAt === "number" && - Number.isFinite(o.queuedAt) - ); + if (typeof v !== "object" || v === null) return false; + const o = v as Record; + return ( + typeof o.id === "string" && + typeof o.text === "string" && + typeof o.queuedAt === "number" && + Number.isFinite(o.queuedAt) + ); } export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null { - if (typeof payload !== "object" || payload === null) return null; - const obj = payload as Record; - const raw = obj.messages; - if (!Array.isArray(raw)) return null; - const messages: QueuedMessage[] = []; - for (const entry of raw) { - if (!isQueuedMessage(entry)) return null; - messages.push(entry); - } - return { messages }; + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record; + const raw = obj.messages; + if (!Array.isArray(raw)) return null; + const messages: QueuedMessage[] = []; + for (const entry of raw) { + if (!isQueuedMessage(entry)) return null; + messages.push(entry); + } + return { messages }; } /** The `rendererId` the message-queue extension's `custom` surface field uses. */ diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index be296a7..9c8a34d 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -4,247 +4,247 @@ import { buildInvoke, groupRenderFields, planSurface } from "./plan"; import type { FieldView } from "./types"; const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({ - id: "test-surface", - region: "test", - title: "Test Surface", - fields, + id: "test-surface", + region: "test", + title: "Test Surface", + fields, }); describe("planSurface", () => { - it("maps a toggle field to a ToggleFieldView", () => { - const plan = planSurface( - makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), - ); - expect(plan.fields).toEqual([ - { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, - ]); - }); - - it("maps a progress field to a ProgressFieldView", () => { - const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); - expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); - }); - - it("maps a selector field to a SelectorFieldView", () => { - const plan = planSurface( - makeSpec({ - kind: "selector", - label: "Model", - value: "gpt-4", - options: [ - { value: "gpt-4", label: "GPT-4" }, - { value: "gpt-3.5", label: "GPT-3.5" }, - ], - action: { actionId: "set-model" }, - }), - ); - expect(plan.fields).toEqual([ - { - kind: "selector", - label: "Model", - value: "gpt-4", - options: [ - { value: "gpt-4", label: "GPT-4" }, - { value: "gpt-3.5", label: "GPT-3.5" }, - ], - action: { actionId: "set-model" }, - }, - ]); - }); - - it("maps a stat field to a StatFieldView", () => { - const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); - expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); - }); - - it("maps a number field to a NumberFieldView, carrying optional hints", () => { - const plan = planSurface( - makeSpec({ - kind: "number", - label: "Interval", - value: 240, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }), - ); - expect(plan.fields).toEqual([ - { - kind: "number", - label: "Interval", - value: 240, - min: 1, - step: 1, - unit: "s", - action: { actionId: "cache-warming/set-interval" }, - }, - ]); - }); - - it("omits absent number hints (no max key when undefined)", () => { - const plan = planSurface( - makeSpec({ - kind: "number", - label: "Interval", - value: 240, - min: 1, - action: { actionId: "set" }, - }), - ); - const field = plan.fields[0]; - expect(field).not.toHaveProperty("max"); - expect(field).not.toHaveProperty("step"); - expect(field).not.toHaveProperty("unit"); - }); - - it("maps a button field to a ButtonFieldView", () => { - const plan = planSurface( - makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), - ); - expect(plan.fields).toEqual([ - { kind: "button", label: "Retry", action: { actionId: "retry" } }, - ]); - }); - - it("preserves field order", () => { - const plan = planSurface( - makeSpec( - { kind: "stat", label: "A", value: "1" }, - { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, - { kind: "progress", label: "C", value: 0.5 }, - { kind: "button", label: "D", action: { actionId: "d" } }, - ), - ); - expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); - }); - - it("drops unknown field kinds gracefully", () => { - const plan = planSurface( - makeSpec({ kind: "stat", label: "Known", value: "ok" }, { - kind: "future-kind" as "stat", - label: "Unknown", - value: "?", - } as SurfaceField), - ); - expect(plan.fields).toHaveLength(1); - const first = plan.fields[0]; - expect(first && "label" in first ? first.label : null).toBe("Known"); - }); - - it("carries custom fields through verbatim, preserving order", () => { - const plan = planSurface( - makeSpec( - { kind: "stat", label: "Before", value: "1" }, - { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, - { kind: "stat", label: "After", value: "2" }, - ), - ); - expect(plan.fields).toHaveLength(3); - expect(plan.fields[1]).toEqual({ - kind: "custom", - rendererId: "chart", - payload: { data: [1, 2, 3] }, - }); - }); - - it("returns empty fields for an empty spec", () => { - const plan = planSurface(makeSpec()); - expect(plan.fields).toEqual([]); - }); - - it("keeps every custom field (render-time decides whether to show each)", () => { - const plan = planSurface( - makeSpec( - { kind: "custom", rendererId: "x", payload: null }, - { kind: "custom", rendererId: "y", payload: 42 }, - ), - ); - expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]); - }); + it("maps a toggle field to a ToggleFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }), + ); + expect(plan.fields).toEqual([ + { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }, + ]); + }); + + it("maps a progress field to a ProgressFieldView", () => { + const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 })); + expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]); + }); + + it("maps a selector field to a SelectorFieldView", () => { + const plan = planSurface( + makeSpec({ + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "selector", + label: "Model", + value: "gpt-4", + options: [ + { value: "gpt-4", label: "GPT-4" }, + { value: "gpt-3.5", label: "GPT-3.5" }, + ], + action: { actionId: "set-model" }, + }, + ]); + }); + + it("maps a stat field to a StatFieldView", () => { + const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" })); + expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]); + }); + + it("maps a number field to a NumberFieldView, carrying optional hints", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }), + ); + expect(plan.fields).toEqual([ + { + kind: "number", + label: "Interval", + value: 240, + min: 1, + step: 1, + unit: "s", + action: { actionId: "cache-warming/set-interval" }, + }, + ]); + }); + + it("omits absent number hints (no max key when undefined)", () => { + const plan = planSurface( + makeSpec({ + kind: "number", + label: "Interval", + value: 240, + min: 1, + action: { actionId: "set" }, + }), + ); + const field = plan.fields[0]; + expect(field).not.toHaveProperty("max"); + expect(field).not.toHaveProperty("step"); + expect(field).not.toHaveProperty("unit"); + }); + + it("maps a button field to a ButtonFieldView", () => { + const plan = planSurface( + makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }), + ); + expect(plan.fields).toEqual([ + { kind: "button", label: "Retry", action: { actionId: "retry" } }, + ]); + }); + + it("preserves field order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "A", value: "1" }, + { kind: "toggle", label: "B", value: false, action: { actionId: "b" } }, + { kind: "progress", label: "C", value: 0.5 }, + { kind: "button", label: "D", action: { actionId: "d" } }, + ), + ); + expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); + }); + + it("drops unknown field kinds gracefully", () => { + const plan = planSurface( + makeSpec({ kind: "stat", label: "Known", value: "ok" }, { + kind: "future-kind" as "stat", + label: "Unknown", + value: "?", + } as SurfaceField), + ); + expect(plan.fields).toHaveLength(1); + const first = plan.fields[0]; + expect(first && "label" in first ? first.label : null).toBe("Known"); + }); + + it("carries custom fields through verbatim, preserving order", () => { + const plan = planSurface( + makeSpec( + { kind: "stat", label: "Before", value: "1" }, + { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } }, + { kind: "stat", label: "After", value: "2" }, + ), + ); + expect(plan.fields).toHaveLength(3); + expect(plan.fields[1]).toEqual({ + kind: "custom", + rendererId: "chart", + payload: { data: [1, 2, 3] }, + }); + }); + + it("returns empty fields for an empty spec", () => { + const plan = planSurface(makeSpec()); + expect(plan.fields).toEqual([]); + }); + + it("keeps every custom field (render-time decides whether to show each)", () => { + const plan = planSurface( + makeSpec( + { kind: "custom", rendererId: "x", payload: null }, + { kind: "custom", rendererId: "y", payload: 42 }, + ), + ); + expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]); + }); }); describe("groupRenderFields", () => { - const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value }); - const toggle = (label: string): FieldView => ({ - kind: "toggle", - label, - value: false, - action: { actionId: label }, - }); - - it("coalesces consecutive stats into a single stats group", () => { - const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]); - expect(groups).toHaveLength(1); - expect(groups[0]).toEqual({ - type: "stats", - stats: [ - { kind: "stat", label: "a", value: "1" }, - { kind: "stat", label: "b", value: "2" }, - { kind: "stat", label: "c", value: "3" }, - ], - }); - }); - - it("keeps non-stat fields as standalone groups and preserves order", () => { - const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]); - expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); - const first = groups[0]; - const last = groups[2]; - if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping"); - expect(first.stats.map((s) => s.label)).toEqual(["a"]); - expect(last.stats.map((s) => s.label)).toEqual(["b"]); - }); - - it("starts a new stats run after an interrupting field", () => { - const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]); - expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); - }); - - it("returns no groups for an empty field list", () => { - expect(groupRenderFields([])).toEqual([]); - }); + const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value }); + const toggle = (label: string): FieldView => ({ + kind: "toggle", + label, + value: false, + action: { actionId: label }, + }); + + it("coalesces consecutive stats into a single stats group", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]); + expect(groups).toHaveLength(1); + expect(groups[0]).toEqual({ + type: "stats", + stats: [ + { kind: "stat", label: "a", value: "1" }, + { kind: "stat", label: "b", value: "2" }, + { kind: "stat", label: "c", value: "3" }, + ], + }); + }); + + it("keeps non-stat fields as standalone groups and preserves order", () => { + const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + const first = groups[0]; + const last = groups[2]; + if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping"); + expect(first.stats.map((s) => s.label)).toEqual(["a"]); + expect(last.stats.map((s) => s.label)).toEqual(["b"]); + }); + + it("starts a new stats run after an interrupting field", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + }); + + it("returns no groups for an empty field list", () => { + expect(groupRenderFields([])).toEqual([]); + }); }); describe("buildInvoke", () => { - it("builds an invoke message for a toggle field", () => { - const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; - const msg = buildInvoke("s1", field, true); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); - }); - - it("builds an invoke message for a selector field", () => { - const field = { - kind: "selector" as const, - label: "S", - value: "a", - options: [], - action: { actionId: "sel" }, - }; - const msg = buildInvoke("s1", field, "b"); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); - }); - - it("builds an invoke message without payload for a button field", () => { - const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; - const msg = buildInvoke("s1", field); - expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); - }); - - it("omits payload key when value is undefined", () => { - const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; - const msg = buildInvoke("s1", field, undefined); - expect(msg).not.toHaveProperty("payload"); - }); - - it("uses the field's actionId, not a surface-level id", () => { - const field = { - kind: "toggle" as const, - label: "X", - value: true, - action: { actionId: "custom-action-123" }, - }; - const msg = buildInvoke("surf", field, false); - expect(msg.actionId).toBe("custom-action-123"); - }); + it("builds an invoke message for a toggle field", () => { + const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } }; + const msg = buildInvoke("s1", field, true); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true }); + }); + + it("builds an invoke message for a selector field", () => { + const field = { + kind: "selector" as const, + label: "S", + value: "a", + options: [], + action: { actionId: "sel" }, + }; + const msg = buildInvoke("s1", field, "b"); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" }); + }); + + it("builds an invoke message without payload for a button field", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field); + expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" }); + }); + + it("omits payload key when value is undefined", () => { + const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } }; + const msg = buildInvoke("s1", field, undefined); + expect(msg).not.toHaveProperty("payload"); + }); + + it("uses the field's actionId, not a surface-level id", () => { + const field = { + kind: "toggle" as const, + label: "X", + value: true, + action: { actionId: "custom-action-123" }, + }; + const msg = buildInvoke("surf", field, false); + expect(msg.actionId).toBe("custom-action-123"); + }); }); diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts index 89088c3..c8f82b9 100644 --- a/src/features/surface-host/logic/plan.ts +++ b/src/features/surface-host/logic/plan.ts @@ -1,20 +1,20 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; import type { - FieldView, - NumberFieldView, - RenderGroup, - StatFieldView, - SurfaceRenderPlan, + FieldView, + NumberFieldView, + RenderGroup, + StatFieldView, + SurfaceRenderPlan, } from "./types"; const KNOWN_KINDS = new Set([ - "toggle", - "progress", - "selector", - "stat", - "number", - "button", - "custom", + "toggle", + "progress", + "selector", + "stat", + "number", + "button", + "custom", ]); /** @@ -25,73 +25,73 @@ const KNOWN_KINDS = new Set([ * decision (unknown `rendererId` → skipped there), not a planning one. */ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { - const fields: FieldView[] = []; - for (const field of spec.fields) { - if (!KNOWN_KINDS.has(field.kind)) continue; - switch (field.kind) { - case "toggle": - fields.push({ - kind: "toggle", - label: field.label, - value: field.value, - action: field.action, - }); - break; - case "progress": - fields.push({ - kind: "progress", - label: field.label, - value: field.value, - }); - break; - case "selector": - fields.push({ - kind: "selector", - label: field.label, - value: field.value, - options: field.options, - action: field.action, - }); - break; - case "stat": - fields.push({ - kind: "stat", - label: field.label, - value: field.value, - }); - break; - case "number": { - // Carry optional hints only when present (exactOptionalPropertyTypes). - const view: NumberFieldView = { - kind: "number", - label: field.label, - value: field.value, - action: field.action, - ...(field.min !== undefined ? { min: field.min } : {}), - ...(field.max !== undefined ? { max: field.max } : {}), - ...(field.step !== undefined ? { step: field.step } : {}), - ...(field.unit !== undefined ? { unit: field.unit } : {}), - }; - fields.push(view); - break; - } - case "button": - fields.push({ - kind: "button", - label: field.label, - action: field.action, - }); - break; - case "custom": - fields.push({ - kind: "custom", - rendererId: field.rendererId, - payload: field.payload, - }); - break; - } - } - return { fields }; + const fields: FieldView[] = []; + for (const field of spec.fields) { + if (!KNOWN_KINDS.has(field.kind)) continue; + switch (field.kind) { + case "toggle": + fields.push({ + kind: "toggle", + label: field.label, + value: field.value, + action: field.action, + }); + break; + case "progress": + fields.push({ + kind: "progress", + label: field.label, + value: field.value, + }); + break; + case "selector": + fields.push({ + kind: "selector", + label: field.label, + value: field.value, + options: field.options, + action: field.action, + }); + break; + case "stat": + fields.push({ + kind: "stat", + label: field.label, + value: field.value, + }); + break; + case "number": { + // Carry optional hints only when present (exactOptionalPropertyTypes). + const view: NumberFieldView = { + kind: "number", + label: field.label, + value: field.value, + action: field.action, + ...(field.min !== undefined ? { min: field.min } : {}), + ...(field.max !== undefined ? { max: field.max } : {}), + ...(field.step !== undefined ? { step: field.step } : {}), + ...(field.unit !== undefined ? { unit: field.unit } : {}), + }; + fields.push(view); + break; + } + case "button": + fields.push({ + kind: "button", + label: field.label, + action: field.action, + }); + break; + case "custom": + fields.push({ + kind: "custom", + rendererId: field.rendererId, + payload: field.payload, + }); + break; + } + } + return { fields }; } /** @@ -100,24 +100,24 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { * other field stays a standalone `field` group. Order is preserved. Pure. */ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] { - const groups: RenderGroup[] = []; - let run: StatFieldView[] = []; - const flush = (): void => { - if (run.length > 0) { - groups.push({ type: "stats", stats: run }); - run = []; - } - }; - for (const field of fields) { - if (field.kind === "stat") { - run.push(field); - } else { - flush(); - groups.push({ type: "field", field }); - } - } - flush(); - return groups; + const groups: RenderGroup[] = []; + let run: StatFieldView[] = []; + const flush = (): void => { + if (run.length > 0) { + groups.push({ type: "stats", stats: run }); + run = []; + } + }; + for (const field of fields) { + if (field.kind === "stat") { + run.push(field); + } else { + flush(); + groups.push({ type: "field", field }); + } + } + flush(); + return groups; } /** @@ -126,13 +126,13 @@ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] { * for button the payload is omitted. */ export function buildInvoke( - surfaceId: string, - field: Extract, - value?: unknown, + surfaceId: string, + field: Extract, + value?: unknown, ): InvokeMessage { - const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; - if (value !== undefined) { - return { ...base, payload: value }; - } - return base; + const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId }; + if (value !== undefined) { + return { ...base, payload: value }; + } + return base; } diff --git a/src/features/surface-host/logic/table.test.ts b/src/features/surface-host/logic/table.test.ts index e55b3f7..6fb558a 100644 --- a/src/features/surface-host/logic/table.test.ts +++ b/src/features/surface-host/logic/table.test.ts @@ -2,46 +2,46 @@ import { describe, expect, it } from "vitest"; import { parseTablePayload } from "./table"; describe("parseTablePayload", () => { - it("parses a well-formed table payload", () => { - const data = parseTablePayload({ - columns: ["Name", "Version"], - rows: [ - ["alpha", "1.0"], - ["beta", "2.3"], - ], - }); - expect(data).toEqual({ - columns: ["Name", "Version"], - rows: [ - ["alpha", "1.0"], - ["beta", "2.3"], - ], - }); - }); + it("parses a well-formed table payload", () => { + const data = parseTablePayload({ + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }); + expect(data).toEqual({ + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }); + }); - it("coerces numeric and boolean cells to strings", () => { - const data = parseTablePayload({ - columns: ["k", "n", "b"], - rows: [["x", 42, true]], - }); - expect(data?.rows[0]).toEqual(["x", "42", "true"]); - }); + it("coerces numeric and boolean cells to strings", () => { + const data = parseTablePayload({ + columns: ["k", "n", "b"], + rows: [["x", 42, true]], + }); + expect(data?.rows[0]).toEqual(["x", "42", "true"]); + }); - it("accepts an empty rows array", () => { - expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] }); - }); + it("accepts an empty rows array", () => { + expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] }); + }); - it.each([ - ["null", null], - ["a number", 7], - ["a string", "nope"], - ["missing columns", { rows: [] }], - ["missing rows", { columns: ["A"] }], - ["non-string column", { columns: [1], rows: [] }], - ["row that is not an array", { columns: ["A"], rows: ["x"] }], - ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }], - ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }], - ])("returns null for invalid payload: %s", (_label, payload) => { - expect(parseTablePayload(payload)).toBeNull(); - }); + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing columns", { rows: [] }], + ["missing rows", { columns: ["A"] }], + ["non-string column", { columns: [1], rows: [] }], + ["row that is not an array", { columns: ["A"], rows: ["x"] }], + ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }], + ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseTablePayload(payload)).toBeNull(); + }); }); diff --git a/src/features/surface-host/logic/table.ts b/src/features/surface-host/logic/table.ts index 027553c..5d2b831 100644 --- a/src/features/surface-host/logic/table.ts +++ b/src/features/surface-host/logic/table.ts @@ -9,46 +9,46 @@ */ export interface TableData { - readonly columns: readonly string[]; - readonly rows: readonly (readonly string[])[]; + readonly columns: readonly string[]; + readonly rows: readonly (readonly string[])[]; } function isStringArray(v: unknown): v is unknown[] { - return Array.isArray(v); + return Array.isArray(v); } function coerceCell(v: unknown): string | null { - if (typeof v === "string") return v; - if (typeof v === "number" && Number.isFinite(v)) return String(v); - if (typeof v === "boolean") return String(v); - return null; + if (typeof v === "string") return v; + if (typeof v === "number" && Number.isFinite(v)) return String(v); + if (typeof v === "boolean") return String(v); + return null; } export function parseTablePayload(payload: unknown): TableData | null { - if (typeof payload !== "object" || payload === null) return null; - const obj = payload as Record; - - const rawColumns = obj.columns; - const rawRows = obj.rows; - if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null; - - const columns: string[] = []; - for (const col of rawColumns) { - if (typeof col !== "string") return null; - columns.push(col); - } - - const rows: string[][] = []; - for (const row of rawRows) { - if (!Array.isArray(row)) return null; - const cells: string[] = []; - for (const cell of row) { - const c = coerceCell(cell); - if (c === null) return null; - cells.push(c); - } - rows.push(cells); - } - - return { columns, rows }; + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record; + + const rawColumns = obj.columns; + const rawRows = obj.rows; + if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null; + + const columns: string[] = []; + for (const col of rawColumns) { + if (typeof col !== "string") return null; + columns.push(col); + } + + const rows: string[][] = []; + for (const row of rawRows) { + if (!Array.isArray(row)) return null; + const cells: string[] = []; + for (const cell of row) { + const c = coerceCell(cell); + if (c === null) return null; + cells.push(c); + } + rows.push(cells); + } + + return { columns, rows }; } diff --git a/src/features/surface-host/logic/todo.test.ts b/src/features/surface-host/logic/todo.test.ts index 225ecde..66ff036 100644 --- a/src/features/surface-host/logic/todo.test.ts +++ b/src/features/surface-host/logic/todo.test.ts @@ -2,66 +2,66 @@ import { describe, expect, it } from "vitest"; import { parseTodoPayload, type TodoItem } from "./todo"; const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({ - content, - status, + content, + status, }); describe("parseTodoPayload", () => { - it("parses a well-formed payload with items", () => { - const data = parseTodoPayload({ - todos: [ - item("Write tests", "in_progress"), - item("Ship it", "pending"), - item("Read docs", "completed"), - ], - }); - expect(data).toEqual({ - todos: [ - item("Write tests", "in_progress"), - item("Ship it", "pending"), - item("Read docs", "completed"), - ], - }); - }); + it("parses a well-formed payload with items", () => { + const data = parseTodoPayload({ + todos: [ + item("Write tests", "in_progress"), + item("Ship it", "pending"), + item("Read docs", "completed"), + ], + }); + expect(data).toEqual({ + todos: [ + item("Write tests", "in_progress"), + item("Ship it", "pending"), + item("Read docs", "completed"), + ], + }); + }); - it("parses an empty-todos payload", () => { - expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] }); - }); + it("parses an empty-todos payload", () => { + expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] }); + }); - it("preserves item order", () => { - const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] }); - expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]); - }); + it("preserves item order", () => { + const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] }); + expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]); + }); - it("accepts all four status values", () => { - const data = parseTodoPayload({ - todos: [ - item("p", "pending"), - item("i", "in_progress"), - item("c", "completed"), - item("x", "cancelled"), - ], - }); - expect(data?.todos.map((t) => t.status)).toEqual([ - "pending", - "in_progress", - "completed", - "cancelled", - ]); - }); + it("accepts all four status values", () => { + const data = parseTodoPayload({ + todos: [ + item("p", "pending"), + item("i", "in_progress"), + item("c", "completed"), + item("x", "cancelled"), + ], + }); + expect(data?.todos.map((t) => t.status)).toEqual([ + "pending", + "in_progress", + "completed", + "cancelled", + ]); + }); - it.each([ - ["null", null], - ["a number", 7], - ["a string", "nope"], - ["missing todos key", { foo: [] }], - ["todos not an array", { todos: "x" }], - ["entry not an object", { todos: ["x"] }], - ["entry missing content", { todos: [{ status: "pending" }] }], - ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }], - ["entry missing status", { todos: [{ content: "x" }] }], - ["entry with invalid status", { todos: [item("x", "done" as never)] }], - ])("returns null for invalid payload: %s", (_label, payload) => { - expect(parseTodoPayload(payload)).toBeNull(); - }); + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing todos key", { foo: [] }], + ["todos not an array", { todos: "x" }], + ["entry not an object", { todos: ["x"] }], + ["entry missing content", { todos: [{ status: "pending" }] }], + ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }], + ["entry missing status", { todos: [{ content: "x" }] }], + ["entry with invalid status", { todos: [item("x", "done" as never)] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseTodoPayload(payload)).toBeNull(); + }); }); diff --git a/src/features/surface-host/logic/todo.ts b/src/features/surface-host/logic/todo.ts index e442e78..8b8a5ef 100644 --- a/src/features/surface-host/logic/todo.ts +++ b/src/features/surface-host/logic/todo.ts @@ -16,33 +16,33 @@ export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; export interface TodoItem { - readonly content: string; - readonly status: TodoStatus; + readonly content: string; + readonly status: TodoStatus; } export interface TodoData { - readonly todos: readonly TodoItem[]; + readonly todos: readonly TodoItem[]; } const STATUSES = new Set(["pending", "in_progress", "completed", "cancelled"]); function isTodoItem(v: unknown): v is TodoItem { - if (typeof v !== "object" || v === null) return false; - const o = v as Record; - return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status); + if (typeof v !== "object" || v === null) return false; + const o = v as Record; + return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status); } export function parseTodoPayload(payload: unknown): TodoData | null { - if (typeof payload !== "object" || payload === null) return null; - const obj = payload as Record; - const raw = obj.todos; - if (!Array.isArray(raw)) return null; - const todos: TodoItem[] = []; - for (const entry of raw) { - if (!isTodoItem(entry)) return null; - todos.push(entry); - } - return { todos }; + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record; + const raw = obj.todos; + if (!Array.isArray(raw)) return null; + const todos: TodoItem[] = []; + for (const entry of raw) { + if (!isTodoItem(entry)) return null; + todos.push(entry); + } + return { todos }; } /** The `rendererId` the `todo` extension's `custom` surface field uses. */ diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts index 23f8757..11c222f 100644 --- a/src/features/surface-host/logic/types.ts +++ b/src/features/surface-host/logic/types.ts @@ -2,33 +2,33 @@ import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract"; /** Normalised view-model for a toggle field. */ export interface ToggleFieldView { - readonly kind: "toggle"; - readonly label: string; - readonly value: boolean; - readonly action: ActionRef; + readonly kind: "toggle"; + readonly label: string; + readonly value: boolean; + readonly action: ActionRef; } /** Normalised view-model for a progress field. */ export interface ProgressFieldView { - readonly kind: "progress"; - readonly label: string; - readonly value: number; + readonly kind: "progress"; + readonly label: string; + readonly value: number; } /** Normalised view-model for a selector field. */ export interface SelectorFieldView { - readonly kind: "selector"; - readonly label: string; - readonly value: string; - readonly options: readonly SurfaceOption[]; - readonly action: ActionRef; + readonly kind: "selector"; + readonly label: string; + readonly value: string; + readonly options: readonly SurfaceOption[]; + readonly action: ActionRef; } /** Normalised view-model for a stat field. */ export interface StatFieldView { - readonly kind: "stat"; - readonly label: string; - readonly value: string; + readonly kind: "stat"; + readonly label: string; + readonly value: string; } /** @@ -37,21 +37,21 @@ export interface StatFieldView { * the spec omits them). The renderer posts the new number as the action payload. */ export interface NumberFieldView { - readonly kind: "number"; - readonly label: string; - readonly value: number; - readonly min?: number; - readonly max?: number; - readonly step?: number; - readonly unit?: string; - readonly action: ActionRef; + readonly kind: "number"; + readonly label: string; + readonly value: number; + readonly min?: number; + readonly max?: number; + readonly step?: number; + readonly unit?: string; + readonly action: ActionRef; } /** Normalised view-model for a button field. */ export interface ButtonFieldView { - readonly kind: "button"; - readonly label: string; - readonly action: ActionRef; + readonly kind: "button"; + readonly label: string; + readonly action: ActionRef; } /** @@ -60,24 +60,24 @@ export interface ButtonFieldView { * never a surface id) and gracefully skips ids it has no renderer for. */ export interface CustomFieldView { - readonly kind: "custom"; - readonly rendererId: string; - readonly payload: unknown; + readonly kind: "custom"; + readonly rendererId: string; + readonly payload: unknown; } /** A normalised field view-model — one entry per renderable field kind. */ export type FieldView = - | ToggleFieldView - | ProgressFieldView - | SelectorFieldView - | StatFieldView - | NumberFieldView - | ButtonFieldView - | CustomFieldView; + | ToggleFieldView + | ProgressFieldView + | SelectorFieldView + | StatFieldView + | NumberFieldView + | ButtonFieldView + | CustomFieldView; /** The output of `planSurface`: the ordered list of renderable fields. */ export interface SurfaceRenderPlan { - readonly fields: readonly FieldView[]; + readonly fields: readonly FieldView[]; } /** @@ -86,5 +86,5 @@ export interface SurfaceRenderPlan { * GENERIC presentation rule keyed on field kind — it never inspects a surface id. */ export type RenderGroup = - | { readonly type: "stats"; readonly stats: readonly StatFieldView[] } - | { readonly type: "field"; readonly field: Exclude }; + | { readonly type: "stats"; readonly stats: readonly StatFieldView[] } + | { readonly type: "field"; readonly field: Exclude }; diff --git a/src/features/surface-host/ui/Button.svelte b/src/features/surface-host/ui/Button.svelte index 62d7acf..ee9097c 100644 --- a/src/features/surface-host/ui/Button.svelte +++ b/src/features/surface-host/ui/Button.svelte @@ -1,21 +1,21 @@ diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte index 12de970..554fa02 100644 --- a/src/features/surface-host/ui/MessageQueueList.svelte +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -1,22 +1,22 @@ {#if data !== null && data.messages.length > 0} -
    - {#each data.messages as msg (msg.id)} -
  • -

    {msg.text}

    - -
  • - {/each} -
+
    + {#each data.messages as msg (msg.id)} +
  • +

    {msg.text}

    + +
  • + {/each} +
{/if} diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte index 0f3323d..5a67087 100644 --- a/src/features/surface-host/ui/Number.svelte +++ b/src/features/surface-host/ui/Number.svelte @@ -1,43 +1,43 @@ diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte index cba9e0f..e291c79 100644 --- a/src/features/surface-host/ui/Progress.svelte +++ b/src/features/surface-host/ui/Progress.svelte @@ -1,13 +1,13 @@
- {field.label} - {percent}% - {percent}% + {field.label} + {percent}% + {percent}%
diff --git a/src/features/surface-host/ui/Selector.svelte b/src/features/surface-host/ui/Selector.svelte index 2da104f..4cb3536 100644 --- a/src/features/surface-host/ui/Selector.svelte +++ b/src/features/surface-host/ui/Selector.svelte @@ -1,32 +1,32 @@ diff --git a/src/features/surface-host/ui/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte index 415423f..c559352 100644 --- a/src/features/surface-host/ui/StatTable.svelte +++ b/src/features/surface-host/ui/StatTable.svelte @@ -1,21 +1,21 @@
- - - {#each stats as stat, i (i)} - - - - - {/each} - -
{stat.label}{stat.value}
+ + + {#each stats as stat, i (i)} + + + + + {/each} + +
{stat.label}{stat.value}
diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte index 764cc36..e47c122 100644 --- a/src/features/surface-host/ui/SurfaceTable.svelte +++ b/src/features/surface-host/ui/SurfaceTable.svelte @@ -1,14 +1,14 @@ {#if data !== null} - +
{/if} diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index 3f92e3b..aed8d03 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -1,52 +1,49 @@
-

{spec.title}

- {#each groups as group, i (i)} - {#if group.type === "stats"} - - {:else if group.field.kind === "toggle"} - - {:else if group.field.kind === "progress"} - - {:else if group.field.kind === "selector"} - - {:else if group.field.kind === "number"} - - {:else if group.field.kind === "button"} -
diff --git a/src/features/surface-host/ui/TodoList.svelte b/src/features/surface-host/ui/TodoList.svelte index b7b2183..e9b1b58 100644 --- a/src/features/surface-host/ui/TodoList.svelte +++ b/src/features/surface-host/ui/TodoList.svelte @@ -1,61 +1,61 @@ {#if data !== null && data.todos.length > 0} -
    - {#each data.todos as todo, i (i)} -
  • - - - {#if todo.status === "in_progress"} - - {:else if todo.status === "completed"} - - - - {:else if todo.status === "cancelled"} - - - - - {:else} - - - {/if} - +
      + {#each data.todos as todo, i (i)} +
    • + + + {#if todo.status === "in_progress"} + + {:else if todo.status === "completed"} + + + + {:else if todo.status === "cancelled"} + + + + + {:else} + + + {/if} + - - - {todo.content} - -
    • - {/each} -
    + + + {todo.content} + +
  • + {/each} +
{/if} diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte index aec8f4e..0326851 100644 --- a/src/features/surface-host/ui/Toggle.svelte +++ b/src/features/surface-host/ui/Toggle.svelte @@ -1,25 +1,25 @@ diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts index 7c77675..661b341 100644 --- a/src/features/system-prompt/index.ts +++ b/src/features/system-prompt/index.ts @@ -1,17 +1,17 @@ export type { - LoadSystemPrompt, - LoadSystemPromptVariables, - SaveSystemPrompt, - SystemPromptLoadResult, - SystemPromptSaveResult, - SystemPromptVariablesResult, - VariableGroup, + LoadSystemPrompt, + LoadSystemPromptVariables, + SaveSystemPrompt, + SystemPromptLoadResult, + SystemPromptSaveResult, + SystemPromptVariablesResult, + VariableGroup, } from "./logic/view-model"; export { buildTag, groupVariables, insertTag } from "./logic/view-model"; export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "system-prompt", - description: "Global system prompt template builder with variable placeholders", + name: "system-prompt", + description: "Global system prompt template builder with variable placeholders", } as const; diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts index a0736cc..223327b 100644 --- a/src/features/system-prompt/logic/view-model.test.ts +++ b/src/features/system-prompt/logic/view-model.test.ts @@ -1,90 +1,90 @@ import type { SystemPromptVariable } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { - buildIfNotTag, - buildIfTag, - buildTag, - groupVariables, - insertTag, - isDynamicVariable, + buildIfNotTag, + buildIfTag, + buildTag, + groupVariables, + insertTag, + isDynamicVariable, } from "./view-model"; describe("system-prompt view-model", () => { - describe("buildTag", () => { - it("builds a variable placeholder", () => { - expect(buildTag("system", "time")).toBe("[system:time]"); - }); - }); + describe("buildTag", () => { + it("builds a variable placeholder", () => { + expect(buildTag("system", "time")).toBe("[system:time]"); + }); + }); - describe("buildIfTag", () => { - it("builds an opening conditional tag", () => { - expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]"); - }); - }); + describe("buildIfTag", () => { + it("builds an opening conditional tag", () => { + expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]"); + }); + }); - describe("buildIfNotTag", () => { - it("builds a negated opening conditional tag", () => { - expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]"); - }); - }); + describe("buildIfNotTag", () => { + it("builds a negated opening conditional tag", () => { + expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]"); + }); + }); - describe("insertTag", () => { - it("inserts a tag at the cursor position", () => { - expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({ - template: "Hello[system:time] world", - cursor: 18, - }); - }); + describe("insertTag", () => { + it("inserts a tag at the cursor position", () => { + expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({ + template: "Hello[system:time] world", + cursor: 18, + }); + }); - it("replaces the selected text", () => { - const tag = buildTag("file", "README.md"); - expect(insertTag("Hello world", tag, 6, 11)).toEqual({ - template: `Hello ${tag}`, - cursor: 6 + tag.length, - }); - }); + it("replaces the selected text", () => { + const tag = buildTag("file", "README.md"); + expect(insertTag("Hello world", tag, 6, 11)).toEqual({ + template: `Hello ${tag}`, + cursor: 6 + tag.length, + }); + }); - it("inserts at the end", () => { - const tag = buildTag("git", "branch"); - expect(insertTag("", tag, 0, 0)).toEqual({ - template: tag, - cursor: tag.length, - }); - }); - }); + it("inserts at the end", () => { + const tag = buildTag("git", "branch"); + expect(insertTag("", tag, 0, 0)).toEqual({ + template: tag, + cursor: tag.length, + }); + }); + }); - describe("groupVariables", () => { - it("groups by type in first-appearing order", () => { - const variables: SystemPromptVariable[] = [ - { type: "system", name: "time", description: "" }, - { type: "prompt", name: "cwd", description: "" }, - { type: "system", name: "date", description: "" }, - { type: "git", name: "branch", description: "" }, - ]; - const groups = groupVariables(variables); - expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]); - expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]); - expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]); - expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]); - }); + describe("groupVariables", () => { + it("groups by type in first-appearing order", () => { + const variables: SystemPromptVariable[] = [ + { type: "system", name: "time", description: "" }, + { type: "prompt", name: "cwd", description: "" }, + { type: "system", name: "date", description: "" }, + { type: "git", name: "branch", description: "" }, + ]; + const groups = groupVariables(variables); + expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]); + expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]); + expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]); + expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]); + }); - it("returns an empty array when no variables", () => { - expect(groupVariables([])).toEqual([]); - }); - }); + it("returns an empty array when no variables", () => { + expect(groupVariables([])).toEqual([]); + }); + }); - describe("isDynamicVariable", () => { - it("returns true when dynamic is true", () => { - expect( - isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }), - ).toBe(true); - }); + describe("isDynamicVariable", () => { + it("returns true when dynamic is true", () => { + expect( + isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }), + ).toBe(true); + }); - it("returns false when dynamic is missing or false", () => { - expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false); - expect( - isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }), - ).toBe(false); - }); - }); + it("returns false when dynamic is missing or false", () => { + expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false); + expect( + isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }), + ).toBe(false); + }); + }); }); diff --git a/src/features/system-prompt/logic/view-model.ts b/src/features/system-prompt/logic/view-model.ts index 6924208..e202ee3 100644 --- a/src/features/system-prompt/logic/view-model.ts +++ b/src/features/system-prompt/logic/view-model.ts @@ -14,20 +14,20 @@ import type { SystemPromptVariable } from "@dispatch/transport-contract"; // ── Injected ports (composition root adapts the store to these shapes) ───────── export type SystemPromptLoadResult = - | { readonly ok: true; readonly template: string } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; export type LoadSystemPrompt = () => Promise; export type SystemPromptSaveResult = - | { readonly ok: true; readonly template: string } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly template: string } + | { readonly ok: false; readonly error: string }; export type SaveSystemPrompt = (template: string) => Promise; export type SystemPromptVariablesResult = - | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } + | { readonly ok: false; readonly error: string }; export type LoadSystemPromptVariables = () => Promise; @@ -35,24 +35,24 @@ export type LoadSystemPromptVariables = () => Promise(); - for (const v of variables) { - if (!map.has(v.type)) { - map.set(v.type, []); - order.push(v.type); - } - map.get(v.type)?.push(v); - } - return order.map((type) => ({ type, variables: map.get(type) ?? [] })); + const order: string[] = []; + const map = new Map(); + for (const v of variables) { + if (!map.has(v.type)) { + map.set(v.type, []); + order.push(v.type); + } + map.get(v.type)?.push(v); + } + return order.map((type) => ({ type, variables: map.get(type) ?? [] })); } /** Whether a variable is "dynamic" (any name is valid, e.g. `file:`). */ export function isDynamicVariable(variable: SystemPromptVariable): boolean { - return variable.dynamic === true; + return variable.dynamic === true; } diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte index 4e07317..2e92e26 100644 --- a/src/features/system-prompt/ui/SystemPromptBuilder.svelte +++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte @@ -1,242 +1,243 @@ diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 699c845..6ac90a3 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,16 +1,16 @@ export type { Tab, TabsState } from "./tabs"; export { - activeTab, - closeTab, - createTab, - deriveTitle, - initialState, - MIN_HANDLE_LENGTH, - newDraft, - selectTab, - setModel, - setTitle, - shortHandle, + activeTab, + closeTab, + createTab, + deriveTitle, + initialState, + MIN_HANDLE_LENGTH, + newDraft, + selectTab, + setModel, + setTitle, + shortHandle, } from "./tabs"; export type { TabsStorage, TabsStore } from "./tabs-store.svelte"; export { createTabsStore } from "./tabs-store.svelte"; @@ -18,6 +18,6 @@ export { default as TabBar } from "./ui/TabBar.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "tabs", - description: "Conversation tabs with title derivation and persistence", + name: "tabs", + description: "Conversation tabs with title derivation and persistence", } as const; diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index 2e876f9..d044b1f 100644 --- a/src/features/tabs/tabs-store.svelte.ts +++ b/src/features/tabs/tabs-store.svelte.ts @@ -1,73 +1,73 @@ import type { Tab, TabsState } from "./tabs"; import { - initialState, - closeTab as reduceCloseTab, - createTab as reduceCreateTab, - newDraft as reduceNewDraft, - openTab as reduceOpenTab, - selectTab as reduceSelectTab, - setModel as reduceSetModel, - setTitle as reduceSetTitle, - activeTab as selectActiveTab, + initialState, + closeTab as reduceCloseTab, + createTab as reduceCreateTab, + newDraft as reduceNewDraft, + openTab as reduceOpenTab, + selectTab as reduceSelectTab, + setModel as reduceSetModel, + setTitle as reduceSetTitle, + activeTab as selectActiveTab, } from "./tabs"; export interface TabsStorage { - load(): TabsState | null; - save(state: TabsState): void; + load(): TabsState | null; + save(state: TabsState): void; } export interface TabsStore { - readonly tabs: readonly Tab[]; - readonly activeConversationId: string | null; - readonly activeTab: Tab | null; - newDraft(): void; - createTab(tab: Tab): void; - /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */ - openTab(tab: Tab): void; - selectTab(conversationId: string): void; - closeTab(conversationId: string): void; - setModel(conversationId: string, model: string): void; - setTitle(conversationId: string, title: string): void; + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; + readonly activeTab: Tab | null; + newDraft(): void; + createTab(tab: Tab): void; + /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */ + openTab(tab: Tab): void; + selectTab(conversationId: string): void; + closeTab(conversationId: string): void; + setModel(conversationId: string, model: string): void; + setTitle(conversationId: string, title: string): void; } export function createTabsStore(storage: TabsStorage): TabsStore { - let state = $state(storage.load() ?? initialState()); + let state = $state(storage.load() ?? initialState()); - function apply(next: TabsState): void { - state = next; - storage.save(next); - } + function apply(next: TabsState): void { + state = next; + storage.save(next); + } - return { - get tabs(): readonly Tab[] { - return state.tabs; - }, - get activeConversationId(): string | null { - return state.activeConversationId; - }, - get activeTab(): Tab | null { - return selectActiveTab(state); - }, - newDraft(): void { - apply(reduceNewDraft(state)); - }, - createTab(tab: Tab): void { - apply(reduceCreateTab(state, tab)); - }, - openTab(tab: Tab): void { - apply(reduceOpenTab(state, tab)); - }, - selectTab(conversationId: string): void { - apply(reduceSelectTab(state, conversationId)); - }, - closeTab(conversationId: string): void { - apply(reduceCloseTab(state, conversationId)); - }, - setModel(conversationId: string, model: string): void { - apply(reduceSetModel(state, conversationId, model)); - }, - setTitle(conversationId: string, title: string): void { - apply(reduceSetTitle(state, conversationId, title)); - }, - }; + return { + get tabs(): readonly Tab[] { + return state.tabs; + }, + get activeConversationId(): string | null { + return state.activeConversationId; + }, + get activeTab(): Tab | null { + return selectActiveTab(state); + }, + newDraft(): void { + apply(reduceNewDraft(state)); + }, + createTab(tab: Tab): void { + apply(reduceCreateTab(state, tab)); + }, + openTab(tab: Tab): void { + apply(reduceOpenTab(state, tab)); + }, + selectTab(conversationId: string): void { + apply(reduceSelectTab(state, conversationId)); + }, + closeTab(conversationId: string): void { + apply(reduceCloseTab(state, conversationId)); + }, + setModel(conversationId: string, model: string): void { + apply(reduceSetModel(state, conversationId, model)); + }, + setTitle(conversationId: string, title: string): void { + apply(reduceSetTitle(state, conversationId, title)); + }, + }; } diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts index 71a38dc..bb4df98 100644 --- a/src/features/tabs/tabs-store.test.ts +++ b/src/features/tabs/tabs-store.test.ts @@ -4,154 +4,154 @@ import type { TabsStorage } from "./tabs-store.svelte"; import { createTabsStore } from "./tabs-store.svelte"; function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsState | null } { - let data: TabsState | null = initial ?? null; - return { - get data() { - return data; - }, - set data(v: TabsState | null) { - data = v; - }, - load() { - return data; - }, - save(state: TabsState) { - data = state; - }, - }; + let data: TabsState | null = initial ?? null; + return { + get data() { + return data; + }, + set data(v: TabsState | null) { + data = v; + }, + load() { + return data; + }, + save(state: TabsState) { + data = state; + }, + }; } describe("createTabsStore", () => { - it("loads persisted state on construct", () => { - const persisted: TabsState = { - tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }], - activeConversationId: "c1", - }; - const storage = createMemoryStorage(persisted); - const store = createTabsStore(storage); - - expect(store.tabs).toHaveLength(1); - expect(store.activeConversationId).toBe("c1"); - expect(store.activeTab?.conversationId).toBe("c1"); - }); - - it("starts with empty draft when no persisted state", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - expect(store.activeTab).toBeNull(); - }); - - it("saves after every mutation", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); - expect(storage.data?.tabs).toHaveLength(1); - expect(storage.data?.activeConversationId).toBe("c1"); - - store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - expect(storage.data?.tabs).toHaveLength(2); - - store.selectTab("c1"); - expect(storage.data?.activeConversationId).toBe("c1"); - - store.closeTab("c1"); - expect(storage.data?.tabs).toHaveLength(1); - expect(storage.data?.activeConversationId).toBe("c2"); - - store.setModel("c2", "new-model"); - expect(storage.data?.tabs[0]?.model).toBe("new-model"); - - store.setTitle("c2", "New Title"); - expect(storage.data?.tabs[0]?.title).toBe("New Title"); - - store.newDraft(); - expect(storage.data?.activeConversationId).toBeNull(); - }); - - it("createTab appends and activates", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); - expect(store.tabs).toHaveLength(1); - expect(store.activeConversationId).toBe("c1"); - - store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - expect(store.tabs).toHaveLength(2); - expect(store.activeConversationId).toBe("c2"); - }); - - it("selectTab changes active", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - - store.selectTab("c1"); - expect(store.activeConversationId).toBe("c1"); - }); - - it("closeTab removes and activates neighbour", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" }); - - store.selectTab("c2"); - store.closeTab("c2"); - expect(store.tabs).toHaveLength(2); - expect(store.activeConversationId).toBe("c1"); - }); - - it("closing the last tab returns to draft", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); - store.closeTab("c1"); - expect(store.tabs).toHaveLength(0); - expect(store.activeConversationId).toBeNull(); - }); - - it("setModel updates the right tab", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); - - store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - - store.setModel("c1", "new-model"); - expect(store.tabs[0]?.model).toBe("new-model"); - expect(store.tabs[1]?.model).toBe("m2"); - }); + it("loads persisted state on construct", () => { + const persisted: TabsState = { + tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }], + activeConversationId: "c1", + }; + const storage = createMemoryStorage(persisted); + const store = createTabsStore(storage); + + expect(store.tabs).toHaveLength(1); + expect(store.activeConversationId).toBe("c1"); + expect(store.activeTab?.conversationId).toBe("c1"); + }); + + it("starts with empty draft when no persisted state", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + expect(store.activeTab).toBeNull(); + }); + + it("saves after every mutation", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + expect(storage.data?.tabs).toHaveLength(1); + expect(storage.data?.activeConversationId).toBe("c1"); + + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + expect(storage.data?.tabs).toHaveLength(2); + + store.selectTab("c1"); + expect(storage.data?.activeConversationId).toBe("c1"); + + store.closeTab("c1"); + expect(storage.data?.tabs).toHaveLength(1); + expect(storage.data?.activeConversationId).toBe("c2"); + + store.setModel("c2", "new-model"); + expect(storage.data?.tabs[0]?.model).toBe("new-model"); + + store.setTitle("c2", "New Title"); + expect(storage.data?.tabs[0]?.title).toBe("New Title"); + + store.newDraft(); + expect(storage.data?.activeConversationId).toBeNull(); + }); + + it("createTab appends and activates", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + expect(store.tabs).toHaveLength(1); + expect(store.activeConversationId).toBe("c1"); + + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBe("c2"); + }); + + it("selectTab changes active", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + + store.selectTab("c1"); + expect(store.activeConversationId).toBe("c1"); + }); + + it("closeTab removes and activates neighbour", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" }); + + store.selectTab("c2"); + store.closeTab("c2"); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBe("c1"); + }); + + it("closing the last tab returns to draft", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.closeTab("c1"); + expect(store.tabs).toHaveLength(0); + expect(store.activeConversationId).toBeNull(); + }); + + it("setModel updates the right tab", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); + + store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + + store.setModel("c1", "new-model"); + expect(store.tabs[0]?.model).toBe("new-model"); + expect(store.tabs[1]?.model).toBe("m2"); + }); - it("setTitle updates the right tab", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); + it("setTitle updates the right tab", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); - store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" }); + store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" }); - store.setTitle("c1", "New Title"); - expect(store.tabs[0]?.title).toBe("New Title"); - }); + store.setTitle("c1", "New Title"); + expect(store.tabs[0]?.title).toBe("New Title"); + }); - it("newDraft clears active but keeps tabs", () => { - const storage = createMemoryStorage(); - const store = createTabsStore(storage); + it("newDraft clears active but keeps tabs", () => { + const storage = createMemoryStorage(); + const store = createTabsStore(storage); - store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); - store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); + store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }); + store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" }); - store.newDraft(); - expect(store.tabs).toHaveLength(2); - expect(store.activeConversationId).toBeNull(); - expect(store.activeTab).toBeNull(); - }); + store.newDraft(); + expect(store.tabs).toHaveLength(2); + expect(store.activeConversationId).toBeNull(); + expect(store.activeTab).toBeNull(); + }); }); diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index e3ca4fa..c31d2e7 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -1,249 +1,249 @@ import { describe, expect, it } from "vitest"; import type { Tab, TabsState } from "./tabs"; import { - activeTab, - closeTab, - createTab, - deriveTitle, - initialState, - isStuckToEnd, - MIN_HANDLE_LENGTH, - newDraft, - selectTab, - setModel, - setTitle, - shortHandle, + activeTab, + closeTab, + createTab, + deriveTitle, + initialState, + isStuckToEnd, + MIN_HANDLE_LENGTH, + newDraft, + selectTab, + setModel, + setTitle, + shortHandle, } from "./tabs"; const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({ - conversationId, - model, - title, - workspaceId: "default", + conversationId, + model, + title, + workspaceId: "default", }); describe("initialState", () => { - it("returns empty draft state when no persisted state", () => { - const state = initialState(); - expect(state.tabs).toEqual([]); - expect(state.activeConversationId).toBeNull(); - }); - - it("returns persisted state when provided", () => { - const persisted: TabsState = { - tabs: [tab("c1")], - activeConversationId: "c1", - }; - const state = initialState(persisted); - expect(state.tabs).toHaveLength(1); - expect(state.activeConversationId).toBe("c1"); - }); + it("returns empty draft state when no persisted state", () => { + const state = initialState(); + expect(state.tabs).toEqual([]); + expect(state.activeConversationId).toBeNull(); + }); + + it("returns persisted state when provided", () => { + const persisted: TabsState = { + tabs: [tab("c1")], + activeConversationId: "c1", + }; + const state = initialState(persisted); + expect(state.tabs).toHaveLength(1); + expect(state.activeConversationId).toBe("c1"); + }); }); describe("newDraft", () => { - it("sets activeConversationId to null", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = newDraft(state); - expect(next.activeConversationId).toBeNull(); - }); - - it("keeps existing tabs", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = newDraft(state); - expect(next.tabs).toHaveLength(2); - }); + it("sets activeConversationId to null", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = newDraft(state); + expect(next.activeConversationId).toBeNull(); + }); + + it("keeps existing tabs", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = newDraft(state); + expect(next.tabs).toHaveLength(2); + }); }); describe("createTab", () => { - it("appends and activates", () => { - const state = initialState(); - const next = createTab(state, tab("c1")); - expect(next.tabs).toHaveLength(1); - expect(next.tabs[0]?.conversationId).toBe("c1"); - expect(next.activeConversationId).toBe("c1"); - }); - - it("does not duplicate an existing conversationId", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = createTab(state, tab("c1")); - expect(next.tabs).toHaveLength(1); - }); - - it("activates an already-existing tab when createTab is called again", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; - const next = createTab(state, tab("c1")); - expect(next.activeConversationId).toBe("c1"); - }); + it("appends and activates", () => { + const state = initialState(); + const next = createTab(state, tab("c1")); + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]?.conversationId).toBe("c1"); + expect(next.activeConversationId).toBe("c1"); + }); + + it("does not duplicate an existing conversationId", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = createTab(state, tab("c1")); + expect(next.tabs).toHaveLength(1); + }); + + it("activates an already-existing tab when createTab is called again", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; + const next = createTab(state, tab("c1")); + expect(next.activeConversationId).toBe("c1"); + }); }); describe("selectTab", () => { - it("changes active", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = selectTab(state, "c2"); - expect(next.activeConversationId).toBe("c2"); - }); + it("changes active", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = selectTab(state, "c2"); + expect(next.activeConversationId).toBe("c2"); + }); }); describe("closeTab", () => { - it("removes the tab", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = closeTab(state, "c2"); - expect(next.tabs).toHaveLength(1); - expect(next.tabs[0]?.conversationId).toBe("c1"); - }); - - it("closing the active tab activates a neighbour (previous preferred)", () => { - const state: TabsState = { - tabs: [tab("c1"), tab("c2"), tab("c3")], - activeConversationId: "c2", - }; - const next = closeTab(state, "c2"); - expect(next.activeConversationId).toBe("c1"); - }); - - it("closing the first active tab activates the next", () => { - const state: TabsState = { - tabs: [tab("c1"), tab("c2"), tab("c3")], - activeConversationId: "c1", - }; - const next = closeTab(state, "c1"); - expect(next.activeConversationId).toBe("c2"); - }); - - it("closing the last tab returns to draft (null active)", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = closeTab(state, "c1"); - expect(next.tabs).toHaveLength(0); - expect(next.activeConversationId).toBeNull(); - }); - - it("closing a non-active tab does not change active", () => { - const state: TabsState = { - tabs: [tab("c1"), tab("c2"), tab("c3")], - activeConversationId: "c3", - }; - const next = closeTab(state, "c1"); - expect(next.activeConversationId).toBe("c3"); - }); - - it("closing a non-existent tab is a no-op", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; - const next = closeTab(state, "missing"); - expect(next).toEqual(state); - }); + it("removes the tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = closeTab(state, "c2"); + expect(next.tabs).toHaveLength(1); + expect(next.tabs[0]?.conversationId).toBe("c1"); + }); + + it("closing the active tab activates a neighbour (previous preferred)", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c2", + }; + const next = closeTab(state, "c2"); + expect(next.activeConversationId).toBe("c1"); + }); + + it("closing the first active tab activates the next", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c1", + }; + const next = closeTab(state, "c1"); + expect(next.activeConversationId).toBe("c2"); + }); + + it("closing the last tab returns to draft (null active)", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = closeTab(state, "c1"); + expect(next.tabs).toHaveLength(0); + expect(next.activeConversationId).toBeNull(); + }); + + it("closing a non-active tab does not change active", () => { + const state: TabsState = { + tabs: [tab("c1"), tab("c2"), tab("c3")], + activeConversationId: "c3", + }; + const next = closeTab(state, "c1"); + expect(next.activeConversationId).toBe("c3"); + }); + + it("closing a non-existent tab is a no-op", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" }; + const next = closeTab(state, "missing"); + expect(next).toEqual(state); + }); }); describe("setModel", () => { - it("updates the right tab", () => { - const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" }; - const next = setModel(state, "c1", "new-model"); - expect(next.tabs[0]?.model).toBe("new-model"); - expect(next.tabs[1]?.model).toBe("default"); - }); + it("updates the right tab", () => { + const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" }; + const next = setModel(state, "c1", "new-model"); + expect(next.tabs[0]?.model).toBe("new-model"); + expect(next.tabs[1]?.model).toBe("default"); + }); }); describe("setTitle", () => { - it("updates the right tab", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; - const next = setTitle(state, "c1", "Updated title"); - expect(next.tabs[0]?.title).toBe("Updated title"); - expect(next.tabs[1]?.title).toBe("Chat"); - }); + it("updates the right tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" }; + const next = setTitle(state, "c1", "Updated title"); + expect(next.tabs[0]?.title).toBe("Updated title"); + expect(next.tabs[1]?.title).toBe("Chat"); + }); }); describe("activeTab", () => { - it("returns the active tab", () => { - const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; - expect(activeTab(state)?.conversationId).toBe("c2"); - }); - - it("returns null when activeConversationId is null", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: null }; - expect(activeTab(state)).toBeNull(); - }); - - it("returns null when active tab is not found in tabs", () => { - const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" }; - expect(activeTab(state)).toBeNull(); - }); + it("returns the active tab", () => { + const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" }; + expect(activeTab(state)?.conversationId).toBe("c2"); + }); + + it("returns null when activeConversationId is null", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: null }; + expect(activeTab(state)).toBeNull(); + }); + + it("returns null when active tab is not found in tabs", () => { + const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" }; + expect(activeTab(state)).toBeNull(); + }); }); describe("deriveTitle", () => { - it("truncates long messages with ellipsis", () => { - const msg = "This is a very long message that should be truncated at some point"; - expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026"); - }); - - it("returns full message when under max", () => { - expect(deriveTitle("Short", 40)).toBe("Short"); - }); - - it("collapses whitespace", () => { - expect(deriveTitle(" hello world ")).toBe("hello world"); - }); - - it("falls back to 'New chat' for empty input", () => { - expect(deriveTitle("")).toBe("New chat"); - expect(deriveTitle(" ")).toBe("New chat"); - }); - - it("uses default max of ~40 chars", () => { - const msg = "a".repeat(50); - const result = deriveTitle(msg); - expect(result).toBe(`${"a".repeat(40)}\u2026`); - }); + it("truncates long messages with ellipsis", () => { + const msg = "This is a very long message that should be truncated at some point"; + expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026"); + }); + + it("returns full message when under max", () => { + expect(deriveTitle("Short", 40)).toBe("Short"); + }); + + it("collapses whitespace", () => { + expect(deriveTitle(" hello world ")).toBe("hello world"); + }); + + it("falls back to 'New chat' for empty input", () => { + expect(deriveTitle("")).toBe("New chat"); + expect(deriveTitle(" ")).toBe("New chat"); + }); + + it("uses default max of ~40 chars", () => { + const msg = "a".repeat(50); + const result = deriveTitle(msg); + expect(result).toBe(`${"a".repeat(40)}\u2026`); + }); }); 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); - }); + 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"]); - expect(h).toBe("3f9a"); - expect(h.length).toBe(MIN_HANDLE_LENGTH); - }); - - it("grows the prefix until unique among open tabs", () => { - // two ids share the first 5 chars → handle grows to 6 to disambiguate - expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1"); - expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2"); - }); - - it("shrinks back to the minimum when the colliding sibling is gone", () => { - expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd"); - }); - - it("ignores the id itself when present in the list", () => { - expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead"); - }); - - it("returns the whole id when shorter than the minimum length", () => { - expect(shortHandle("ab", ["ab", "cd"])).toBe("ab"); - }); - - it("falls back to the full id when one id is a prefix of another", () => { - // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it - expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); - }); + it("uses the minimum length when the id is unique", () => { + const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]); + expect(h).toBe("3f9a"); + expect(h.length).toBe(MIN_HANDLE_LENGTH); + }); + + it("grows the prefix until unique among open tabs", () => { + // two ids share the first 5 chars → handle grows to 6 to disambiguate + expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1"); + expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2"); + }); + + it("shrinks back to the minimum when the colliding sibling is gone", () => { + expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd"); + }); + + it("ignores the id itself when present in the list", () => { + expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead"); + }); + + it("returns the whole id when shorter than the minimum length", () => { + expect(shortHandle("ab", ["ab", "cd"])).toBe("ab"); + }); + + it("falls back to the full id when one id is a prefix of another", () => { + // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it + expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); + }); }); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 53eda78..bc7e30b 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -1,40 +1,40 @@ export interface Tab { - readonly conversationId: string; - readonly model: string; - readonly title: string; - /** The workspace this tab belongs to (the workspace's URL slug). */ - readonly workspaceId: string; + readonly conversationId: string; + readonly model: string; + readonly title: string; + /** The workspace this tab belongs to (the workspace's URL slug). */ + readonly workspaceId: string; } export interface TabsState { - readonly tabs: readonly Tab[]; - readonly activeConversationId: string | null; + readonly tabs: readonly Tab[]; + readonly activeConversationId: string | null; } const DEFAULT_TITLE = "New chat"; const DEFAULT_MAX_TITLE_LENGTH = 40; export function initialState(persisted?: TabsState): TabsState { - if (persisted !== undefined) { - // Migrate tabs persisted before workspaces: assign them to the "default" - // workspace (the fallback for conversations with no workspace). - const tabs = persisted.tabs.map((t) => { - const wid = (t as { workspaceId?: string }).workspaceId; - return { ...t, workspaceId: wid ?? "default" }; - }); - return { tabs, activeConversationId: persisted.activeConversationId }; - } - return { tabs: [], activeConversationId: null }; + if (persisted !== undefined) { + // Migrate tabs persisted before workspaces: assign them to the "default" + // workspace (the fallback for conversations with no workspace). + const tabs = persisted.tabs.map((t) => { + const wid = (t as { workspaceId?: string }).workspaceId; + return { ...t, workspaceId: wid ?? "default" }; + }); + return { tabs, activeConversationId: persisted.activeConversationId }; + } + return { tabs: [], activeConversationId: null }; } export function newDraft(state: TabsState): TabsState { - return { ...state, activeConversationId: null }; + return { ...state, activeConversationId: null }; } export function createTab(state: TabsState, tab: Tab): TabsState { - const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); - const tabs = exists ? state.tabs : [...state.tabs, tab]; - return { tabs, activeConversationId: tab.conversationId }; + const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); + const tabs = exists ? state.tabs : [...state.tabs, tab]; + return { tabs, activeConversationId: tab.conversationId }; } /** @@ -43,54 +43,54 @@ export function createTab(state: TabsState, tab: Tab): TabsState { * strip but the user stays on their current tab. No-op if already open. */ export function openTab(state: TabsState, tab: Tab): TabsState { - const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); - if (exists) return state; - return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId }; + const exists = state.tabs.some((t) => t.conversationId === tab.conversationId); + if (exists) return state; + return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId }; } export function selectTab(state: TabsState, conversationId: string): TabsState { - return { ...state, activeConversationId: conversationId }; + return { ...state, activeConversationId: conversationId }; } export function closeTab(state: TabsState, conversationId: string): TabsState { - const idx = state.tabs.findIndex((t) => t.conversationId === conversationId); - if (idx === -1) return state; + const idx = state.tabs.findIndex((t) => t.conversationId === conversationId); + if (idx === -1) return state; - const tabs = state.tabs.filter((t) => t.conversationId !== conversationId); + const tabs = state.tabs.filter((t) => t.conversationId !== conversationId); - if (state.activeConversationId !== conversationId) { - return { tabs, activeConversationId: state.activeConversationId }; - } + if (state.activeConversationId !== conversationId) { + return { tabs, activeConversationId: state.activeConversationId }; + } - if (tabs.length === 0) { - return { tabs, activeConversationId: null }; - } + if (tabs.length === 0) { + return { tabs, activeConversationId: null }; + } - // prefer previous tab, else next - const neighborIdx = idx > 0 ? idx - 1 : 0; - const neighbor = tabs[neighborIdx]; - return { tabs, activeConversationId: neighbor?.conversationId ?? null }; + // prefer previous tab, else next + const neighborIdx = idx > 0 ? idx - 1 : 0; + const neighbor = tabs[neighborIdx]; + return { tabs, activeConversationId: neighbor?.conversationId ?? null }; } export function setModel(state: TabsState, conversationId: string, model: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); - return { tabs, activeConversationId: state.activeConversationId }; + const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); + return { tabs, activeConversationId: state.activeConversationId }; } export function setTitle(state: TabsState, conversationId: string, title: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t)); - return { tabs, activeConversationId: state.activeConversationId }; + const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t)); + return { tabs, activeConversationId: state.activeConversationId }; } export function activeTab(state: TabsState): Tab | null { - if (state.activeConversationId === null) return null; - return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; + if (state.activeConversationId === null) return null; + return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; } export interface ScrollMetrics { - readonly scrollLeft: number; - readonly clientWidth: number; - readonly scrollWidth: number; + readonly scrollLeft: number; + readonly clientWidth: number; + readonly scrollWidth: number; } const STUCK_EPSILON = 1; @@ -102,16 +102,16 @@ const STUCK_EPSILON = 1; * 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; + 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; - if (trimmed.length <= max) return trimmed; - return `${trimmed.slice(0, max)}\u2026`; + const trimmed = message.trim().replace(/\s+/g, " "); + if (trimmed.length === 0) return DEFAULT_TITLE; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max)}\u2026`; } /** Minimum length of a tab handle (git-style short id). */ @@ -125,10 +125,10 @@ export const MIN_HANDLE_LENGTH = 4; * id in, the handle string out. (`allIds` may include `conversationId` itself.) */ export function shortHandle(conversationId: string, allIds: readonly string[]): string { - const others = allIds.filter((id) => id !== conversationId); - for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) { - const candidate = conversationId.slice(0, len); - if (!others.some((id) => id.startsWith(candidate))) return candidate; - } - return conversationId; + const others = allIds.filter((id) => id !== conversationId); + for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) { + const candidate = conversationId.slice(0, len); + if (!others.some((id) => id.startsWith(candidate))) return candidate; + } + return conversationId; } diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index a46b692..087b28c 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -5,209 +5,209 @@ import type { Tab } from "./tabs"; import TabBar from "./ui/TabBar.svelte"; const sampleTabs: readonly Tab[] = [ - { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" }, - { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" }, - { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" }, + { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" }, + { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" }, + { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" }, ]; describe("TabBar", () => { - it("renders one role=tab element per tab showing each title", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const tabs = screen.getAllByRole("tab"); - expect(tabs).toHaveLength(sampleTabs.length); - expect(tabs[0]).toHaveTextContent("First"); - expect(tabs[1]).toHaveTextContent("Second"); - expect(tabs[2]).toHaveTextContent("Third"); - }); - - it("applies tab-active to the active tab only", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c2", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - 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"); - }); - - it("calls onSelect with the conversationId when a tab is clicked", async () => { - const onSelect = vi.fn(); - const onClose = vi.fn(); - const user = userEvent.setup(); - - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect, - onClose, - onNewDraft: vi.fn(), - }, - }); - - const tabs = screen.getAllByRole("tab"); - const secondTab = tabs[1]; - if (!secondTab) throw new Error("second tab not found"); - await user.click(secondTab); - - expect(onSelect).toHaveBeenCalledTimes(1); - expect(onSelect).toHaveBeenCalledWith("c2"); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("calls onClose when the close button is clicked and does not call onSelect", async () => { - const onSelect = vi.fn(); - const onClose = vi.fn(); - const user = userEvent.setup(); - - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect, - onClose, - onNewDraft: vi.fn(), - }, - }); - - const closeButtons = screen.getAllByRole("button", { name: "Close tab" }); - const firstClose = closeButtons[0]; - if (!firstClose) throw new Error("first close button not found"); - await user.click(firstClose); - - expect(onClose).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledWith("c1"); - expect(onSelect).not.toHaveBeenCalled(); - }); - - it("calls onNewDraft when the New chat button is clicked", async () => { - const onNewDraft = vi.fn(); - const user = userEvent.setup(); - - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft, - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - await user.click(newChat); - - 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, { - props: { - tabs: sampleTabs, - activeConversationId: null, - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - const newChat = screen.getByRole("button", { name: "New chat" }); - expect(newChat).toHaveTextContent("New Chat"); - }); - - it("does not show 'New Chat' text when a real tab is active", () => { - 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).not.toHaveTextContent("New Chat"); - }); - - it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => { - const tabs: readonly Tab[] = [ - { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" }, - { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" }, - ]; - render(TabBar, { - props: { - tabs, - activeConversationId: "3f9a1b2c-1111", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - expect(screen.getByText("3f9a")).toBeInTheDocument(); - expect(screen.getByText("7c2d")).toBeInTheDocument(); - }); - - it("renders fixed-width tabs", () => { - render(TabBar, { - props: { - tabs: sampleTabs, - activeConversationId: "c1", - onSelect: vi.fn(), - onClose: vi.fn(), - onNewDraft: vi.fn(), - }, - }); - - for (const t of screen.getAllByRole("tab")) { - expect(t).toHaveClass("w-48"); - } - }); + it("renders one role=tab element per tab showing each title", () => { + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(sampleTabs.length); + expect(tabs[0]).toHaveTextContent("First"); + expect(tabs[1]).toHaveTextContent("Second"); + expect(tabs[2]).toHaveTextContent("Third"); + }); + + it("applies tab-active to the active tab only", () => { + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c2", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + 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"); + }); + + it("calls onSelect with the conversationId when a tab is clicked", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const user = userEvent.setup(); + + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose, + onNewDraft: vi.fn(), + }, + }); + + const tabs = screen.getAllByRole("tab"); + const secondTab = tabs[1]; + if (!secondTab) throw new Error("second tab not found"); + await user.click(secondTab); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("c2"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("calls onClose when the close button is clicked and does not call onSelect", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + const user = userEvent.setup(); + + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect, + onClose, + onNewDraft: vi.fn(), + }, + }); + + const closeButtons = screen.getAllByRole("button", { name: "Close tab" }); + const firstClose = closeButtons[0]; + if (!firstClose) throw new Error("first close button not found"); + await user.click(firstClose); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("c1"); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("calls onNewDraft when the New chat button is clicked", async () => { + const onNewDraft = vi.fn(); + const user = userEvent.setup(); + + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft, + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + await user.click(newChat); + + 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, { + props: { + tabs: sampleTabs, + activeConversationId: null, + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + const newChat = screen.getByRole("button", { name: "New chat" }); + expect(newChat).toHaveTextContent("New Chat"); + }); + + it("does not show 'New Chat' text when a real tab is active", () => { + 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).not.toHaveTextContent("New Chat"); + }); + + it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => { + const tabs: readonly Tab[] = [ + { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" }, + { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" }, + ]; + render(TabBar, { + props: { + tabs, + activeConversationId: "3f9a1b2c-1111", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + expect(screen.getByText("3f9a")).toBeInTheDocument(); + expect(screen.getByText("7c2d")).toBeInTheDocument(); + }); + + it("renders fixed-width tabs", () => { + render(TabBar, { + props: { + tabs: sampleTabs, + activeConversationId: "c1", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + + for (const t of screen.getAllByRole("tab")) { + expect(t).toHaveClass("w-48"); + } + }); }); diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte index 7ec2101..211fd5c 100644 --- a/src/features/tabs/ui/TabBar.svelte +++ b/src/features/tabs/ui/TabBar.svelte @@ -1,178 +1,177 @@
-
- {#each tabs as tab (tab.conversationId)} - - {/each} - -
+
+ {#each tabs as tab (tab.conversationId)} + + {/each} + +
diff --git a/src/features/views/index.ts b/src/features/views/index.ts index c4e7f25..164241d 100644 --- a/src/features/views/index.ts +++ b/src/features/views/index.ts @@ -1,15 +1,15 @@ export { - addPanel, - initialPanels, - type PanelsState, - removePanel, - selectKind, - type ViewPanel, + addPanel, + initialPanels, + type PanelsState, + removePanel, + selectKind, + type ViewPanel, } from "./logic/panels"; export { default as ViewSidebar } from "./ui/ViewSidebar.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "views", - description: "Sidebar view panels (dropdown picker + add / remove)", + name: "views", + description: "Sidebar view panels (dropdown picker + add / remove)", } as const; diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts index edd7d9e..aadbf5e 100644 --- a/src/features/views/logic/panels.test.ts +++ b/src/features/views/logic/panels.test.ts @@ -2,54 +2,54 @@ import { describe, expect, it } from "vitest"; import { addPanel, initialPanels, removePanel, selectKind } from "./panels"; describe("view panels reducer", () => { - it("seeds one empty panel by default", () => { - const s = initialPanels(); - expect(s.panels).toHaveLength(1); - expect(s.panels[0]?.kind).toBeNull(); - }); + it("seeds one empty panel by default", () => { + const s = initialPanels(); + expect(s.panels).toHaveLength(1); + expect(s.panels[0]?.kind).toBeNull(); + }); - it("seeds a panel per provided kind, in order, with unique ids", () => { - const s = initialPanels(["surfaces", null]); - expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]); - expect(new Set(s.panels.map((p) => p.id)).size).toBe(2); - }); + it("seeds a panel per provided kind, in order, with unique ids", () => { + const s = initialPanels(["surfaces", null]); + expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]); + expect(new Set(s.panels.map((p) => p.id)).size).toBe(2); + }); - it("addPanel appends an empty panel with a fresh id", () => { - const seed = initialPanels(["surfaces"]); - const s = addPanel(seed); - expect(s.panels).toHaveLength(2); - expect(s.panels[1]?.kind).toBeNull(); - expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id); - }); + it("addPanel appends an empty panel with a fresh id", () => { + const seed = initialPanels(["surfaces"]); + const s = addPanel(seed); + expect(s.panels).toHaveLength(2); + expect(s.panels[1]?.kind).toBeNull(); + expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id); + }); - it("addPanel can seed a kind", () => { - const s = addPanel(initialPanels([null]), "surfaces"); - expect(s.panels[1]?.kind).toBe("surfaces"); - }); + it("addPanel can seed a kind", () => { + const s = addPanel(initialPanels([null]), "surfaces"); + expect(s.panels[1]?.kind).toBe("surfaces"); + }); - it("removePanel drops the matching id only", () => { - const seed = initialPanels(["surfaces", null]); - const firstId = seed.panels[0]?.id ?? -1; - const s = removePanel(seed, firstId); - expect(s.panels).toHaveLength(1); - expect(s.panels[0]?.kind).toBeNull(); - }); + it("removePanel drops the matching id only", () => { + const seed = initialPanels(["surfaces", null]); + const firstId = seed.panels[0]?.id ?? -1; + const s = removePanel(seed, firstId); + expect(s.panels).toHaveLength(1); + expect(s.panels[0]?.kind).toBeNull(); + }); - it("selectKind updates only the targeted panel", () => { - const seed = initialPanels([null, null]); - const targetId = seed.panels[1]?.id ?? -1; - const s = selectKind(seed, targetId, "surfaces"); - expect(s.panels[0]?.kind).toBeNull(); - expect(s.panels[1]?.kind).toBe("surfaces"); - }); + it("selectKind updates only the targeted panel", () => { + const seed = initialPanels([null, null]); + const targetId = seed.panels[1]?.id ?? -1; + const s = selectKind(seed, targetId, "surfaces"); + expect(s.panels[0]?.kind).toBeNull(); + expect(s.panels[1]?.kind).toBe("surfaces"); + }); - it("is pure — never mutates the input state", () => { - const seed = initialPanels(["surfaces"]); - const snapshot = JSON.stringify(seed); - const id = seed.panels[0]?.id ?? -1; - addPanel(seed); - removePanel(seed, id); - selectKind(seed, id, null); - expect(JSON.stringify(seed)).toBe(snapshot); - }); + it("is pure — never mutates the input state", () => { + const seed = initialPanels(["surfaces"]); + const snapshot = JSON.stringify(seed); + const id = seed.panels[0]?.id ?? -1; + addPanel(seed); + removePanel(seed, id); + selectKind(seed, id, null); + expect(JSON.stringify(seed)).toBe(snapshot); + }); }); diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts index 38c28fb..fa22be6 100644 --- a/src/features/views/logic/panels.ts +++ b/src/features/views/logic/panels.ts @@ -10,14 +10,14 @@ */ export interface ViewPanel { - readonly id: number; - /** Selected view-kind id, or `null` while the panel still reads "Select a view". */ - readonly kind: string | null; + readonly id: number; + /** Selected view-kind id, or `null` while the panel still reads "Select a view". */ + readonly kind: string | null; } export interface PanelsState { - readonly panels: readonly ViewPanel[]; - readonly nextId: number; + readonly panels: readonly ViewPanel[]; + readonly nextId: number; } /** @@ -25,25 +25,25 @@ export interface PanelsState { * a single preset panel, or `[null]` for one empty "Select a view" panel. */ export function initialPanels(kinds: readonly (string | null)[] = [null]): PanelsState { - let nextId = 0; - const panels = kinds.map((kind) => ({ id: nextId++, kind })); - return { panels, nextId }; + let nextId = 0; + const panels = kinds.map((kind) => ({ id: nextId++, kind })); + return { panels, nextId }; } export function addPanel(state: PanelsState, kind: string | null = null): PanelsState { - return { - panels: [...state.panels, { id: state.nextId, kind }], - nextId: state.nextId + 1, - }; + return { + panels: [...state.panels, { id: state.nextId, kind }], + nextId: state.nextId + 1, + }; } export function removePanel(state: PanelsState, id: number): PanelsState { - return { ...state, panels: state.panels.filter((p) => p.id !== id) }; + return { ...state, panels: state.panels.filter((p) => p.id !== id) }; } export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState { - return { - ...state, - panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)), - }; + return { + ...state, + panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)), + }; } diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte index e4a3ee6..e9e8682 100644 --- a/src/features/views/ui/ViewSidebar.svelte +++ b/src/features/views/ui/ViewSidebar.svelte @@ -1,97 +1,95 @@
- {#each state.panels as panel, idx (panel.id)} -
-
- - {#if idx > 0} - - {/if} -
+ {#each state.panels as panel, idx (panel.id)} +
+
+ + {#if idx > 0} + + {/if} +
- {#if panel.kind !== null} -
- {@render content(panel.kind)} -
- {/if} -
- {/each} + {#if panel.kind !== null} +
+ {@render content(panel.kind)} +
+ {/if} +
+ {/each} - +
diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts index 8a0049c..0618506 100644 --- a/src/features/views/ui/ViewSidebar.test.ts +++ b/src/features/views/ui/ViewSidebar.test.ts @@ -5,54 +5,54 @@ import { describe, expect, it } from "vitest"; import ViewSidebar from "./ViewSidebar.svelte"; const kinds = [ - { id: "surfaces", label: "Surfaces" }, - { id: "tasks", label: "Tasks" }, + { id: "surfaces", label: "Surfaces" }, + { id: "tasks", label: "Tasks" }, ]; // A raw snippet that echoes the kind it was rendered for, so tests can assert // which view-kind content each panel shows. const content = createRawSnippet<[string]>((kind) => ({ - render: () => `
kind:${kind()}
`, + render: () => `
kind:${kind()}
`, })); describe("ViewSidebar", () => { - it("opens one panel seeded with the first kind and renders its content", () => { - render(ViewSidebar, { props: { kinds, content } }); - expect(screen.getAllByRole("combobox")).toHaveLength(1); - expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces"); - }); + it("opens one panel seeded with the first kind and renders its content", () => { + render(ViewSidebar, { props: { kinds, content } }); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces"); + }); - it("the first panel has no remove button", () => { - render(ViewSidebar, { props: { kinds, content } }); - expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); - }); + it("the first panel has no remove button", () => { + render(ViewSidebar, { props: { kinds, content } }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + }); - it("the add button appends a new empty panel", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content } }); - await user.click(screen.getByRole("button", { name: "Add view" })); - expect(screen.getAllByRole("combobox")).toHaveLength(2); - // the new panel is empty → only the first panel renders content - expect(screen.getAllByTestId("view-content")).toHaveLength(1); - }); + it("the add button appends a new empty panel", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content } }); + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(screen.getAllByRole("combobox")).toHaveLength(2); + // the new panel is empty → only the first panel renders content + expect(screen.getAllByTestId("view-content")).toHaveLength(1); + }); - it("non-first panels can be removed", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content } }); - await user.click(screen.getByRole("button", { name: "Add view" })); - const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); - expect(removeButtons).toHaveLength(1); - const target = removeButtons[0]; - if (target === undefined) throw new Error("expected a remove button"); - await user.click(target); - expect(screen.getAllByRole("combobox")).toHaveLength(1); - }); + it("non-first panels can be removed", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content } }); + await user.click(screen.getByRole("button", { name: "Add view" })); + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + const target = removeButtons[0]; + if (target === undefined) throw new Error("expected a remove button"); + await user.click(target); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); - it("selecting a kind renders that kind's content", async () => { - const user = userEvent.setup(); - render(ViewSidebar, { props: { kinds, content, initial: [null] } }); - expect(screen.queryByTestId("view-content")).toBeNull(); - await user.selectOptions(screen.getByRole("combobox"), "tasks"); - expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks"); - }); + it("selecting a kind renders that kind's content", async () => { + const user = userEvent.setup(); + render(ViewSidebar, { props: { kinds, content, initial: [null] } }); + expect(screen.queryByTestId("view-content")).toBeNull(); + await user.selectOptions(screen.getByRole("combobox"), "tasks"); + expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks"); + }); }); diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index adaff00..19e53f8 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -3,131 +3,131 @@ import { createWorkspaceHttp } from "./http"; /** Build a fake `fetch` returning a canned Response. */ function fakeFetch(responses: Array<{ status?: number; body?: unknown } | Error>): typeof fetch { - let i = 0; - return vi.fn(async () => { - const next = responses[i++]; - if (next === undefined) throw new Error("fakeFetch: no more canned responses"); - if (next instanceof Error) throw next; - const status = next.status ?? 200; - const body = next.body; - return { - ok: status >= 200 && status < 300, - status, - async json() { - return body; - }, - } as Response; - }) as unknown as typeof fetch; + let i = 0; + return vi.fn(async () => { + const next = responses[i++]; + if (next === undefined) throw new Error("fakeFetch: no more canned responses"); + if (next instanceof Error) throw next; + const status = next.status ?? 200; + const body = next.body; + return { + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, + } as Response; + }) as unknown as typeof fetch; } const BASE = "http://x"; describe("createWorkspaceHttp", () => { - it("list returns the workspaces", async () => { - const fetchImpl = fakeFetch([ - { - body: { - workspaces: [ - { - id: "a", - title: "A", - defaultCwd: null, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - }, - ], - }, - }, - ]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const list = await http.list(); - expect(list).toHaveLength(1); - expect(list[0]?.id).toBe("a"); - expect(list[0]?.conversationCount).toBe(3); - expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); - }); + it("list returns the workspaces", async () => { + const fetchImpl = fakeFetch([ + { + body: { + workspaces: [ + { + id: "a", + title: "A", + defaultCwd: null, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 3, + }, + ], + }, + }, + ]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const list = await http.list(); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe("a"); + expect(list[0]?.conversationCount).toBe(3); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); - it("list returns [] on a failed response (non-fatal)", async () => { - const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }])); - expect(await http.list()).toEqual([]); - }); + it("list returns [] on a failed response (non-fatal)", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }])); + expect(await http.list()).toEqual([]); + }); - it("list returns [] on a network error", async () => { - const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); - expect(await http.list()).toEqual([]); - }); + it("list returns [] on a network error", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); + expect(await http.list()).toEqual([]); + }); - it("ensure PUTs the id + returns the workspace", async () => { - const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 }; - const fetchImpl = fakeFetch([{ body: ws }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const result = await http.ensure("my-ws"); - expect(result).toEqual({ ok: true, value: ws }); - expect(fetchImpl).toHaveBeenCalledWith( - `${BASE}/workspaces/my-ws`, - expect.objectContaining({ method: "PUT" }), - ); - }); + it("ensure PUTs the id + returns the workspace", async () => { + const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.ensure("my-ws"); + expect(result).toEqual({ ok: true, value: ws }); + expect(fetchImpl).toHaveBeenCalledWith( + `${BASE}/workspaces/my-ws`, + expect.objectContaining({ method: "PUT" }), + ); + }); - it("ensure surfaces the backend error on a 400 (invalid slug)", async () => { - const http = createWorkspaceHttp( - BASE, - fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), - ); - const result = await http.ensure("UPPER"); - expect(result).toEqual({ ok: false, error: "invalid slug" }); - }); + it("ensure surfaces the backend error on a 400 (invalid slug)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 400, body: { error: "invalid slug" } }]), + ); + const result = await http.ensure("UPPER"); + expect(result).toEqual({ ok: false, error: "invalid slug" }); + }); - it("get returns null on 404", async () => { - const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); - expect(await http.get("nope")).toBeNull(); - }); + it("get returns null on 404", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); + expect(await http.get("nope")).toBeNull(); + }); - it("get returns the workspace on 200", async () => { - const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; - const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); - expect(await http.get("x")).toEqual(ws); - }); + it("get returns the workspace on 200", async () => { + const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; + const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); + expect(await http.get("x")).toEqual(ws); + }); - it("setTitle PUTs the title", async () => { - const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; - const fetchImpl = fakeFetch([{ body: ws }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const result = await http.setTitle("a", "Renamed"); - expect(result).toEqual({ ok: true, value: ws }); - const call = (fetchImpl as unknown as ReturnType).mock.calls[0]; - expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`); - expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" }); - }); + it("setTitle PUTs the title", async () => { + const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.setTitle("a", "Renamed"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`); + expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" }); + }); - it("setDefaultCwd PUTs null to clear", async () => { - const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; - const fetchImpl = fakeFetch([{ body: ws }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - await http.setDefaultCwd("a", null); - const call = (fetchImpl as unknown as ReturnType).mock.calls[0]; - expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`); - expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null }); - }); + it("setDefaultCwd PUTs null to clear", async () => { + const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.setDefaultCwd("a", null); + const call = (fetchImpl as unknown as ReturnType).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`); + expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null }); + }); - it("delete returns closedCount", async () => { - const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]); - const http = createWorkspaceHttp(BASE, fetchImpl); - const result = await http.delete("a"); - expect(result).toEqual({ ok: true, value: { closedCount: 4 } }); - expect(fetchImpl).toHaveBeenCalledWith( - `${BASE}/workspaces/a`, - expect.objectContaining({ method: "DELETE" }), - ); - }); + it("delete returns closedCount", async () => { + const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.delete("a"); + expect(result).toEqual({ ok: true, value: { closedCount: 4 } }); + expect(fetchImpl).toHaveBeenCalledWith( + `${BASE}/workspaces/a`, + expect.objectContaining({ method: "DELETE" }), + ); + }); - it("delete surfaces 409 for 'default'", async () => { - const http = createWorkspaceHttp( - BASE, - fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]), - ); - const result = await http.delete("default"); - expect(result).toEqual({ ok: false, error: "cannot delete default" }); - }); + it("delete surfaces 409 for 'default'", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]), + ); + const result = await http.delete("default"); + expect(result).toEqual({ ok: false, error: "cannot delete default" }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 6ebfee1..01fe677 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -1,13 +1,13 @@ import type { - DeleteWorkspaceResponse, - EnsureWorkspaceRequest, - SetWorkspaceDefaultComputerRequest, - SetWorkspaceDefaultCwdRequest, - SetWorkspaceTitleRequest, - Workspace, - WorkspaceEntry, - WorkspaceListResponse, - WorkspaceResponse, + DeleteWorkspaceResponse, + EnsureWorkspaceRequest, + SetWorkspaceDefaultComputerRequest, + SetWorkspaceDefaultCwdRequest, + SetWorkspaceTitleRequest, + Workspace, + WorkspaceEntry, + WorkspaceListResponse, + WorkspaceResponse, } from "@dispatch/transport-contract"; /** @@ -28,130 +28,130 @@ import type { * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; export interface WorkspaceHttp { - list(): Promise; - ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; - get(id: string): Promise; - setTitle(id: string, title: string): Promise>; - setDefaultCwd(id: string, defaultCwd: string | null): Promise>; - setDefaultComputer(id: string, computerId: string | null): Promise>; - delete(id: string): Promise>; + list(): Promise; + ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; + get(id: string): Promise; + setTitle(id: string, title: string): Promise>; + setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + setDefaultComputer(id: string, computerId: string | null): Promise>; + delete(id: string): Promise>; } async function errText(res: Response): Promise { - try { - const body = (await res.json()) as { error?: string }; - return body.error ?? `HTTP ${res.status}`; - } catch { - return `HTTP ${res.status}`; - } + try { + const body = (await res.json()) as { error?: string }; + return body.error ?? `HTTP ${res.status}`; + } catch { + return `HTTP ${res.status}`; + } } export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { - return { - async list(): Promise { - try { - const res = await fetchImpl(`${httpBase}/workspaces`); - if (!res.ok) return []; - const data = (await res.json()) as WorkspaceListResponse; - return data.workspaces; - } catch { - return []; - } - }, + return { + async list(): Promise { + try { + const res = await fetchImpl(`${httpBase}/workspaces`); + if (!res.ok) return []; + const data = (await res.json()) as WorkspaceListResponse; + return data.workspaces; + } catch { + return []; + } + }, - async ensure(id, body): Promise> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body ?? {}), - }); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Workspace request failed", - }; - } - }, + async ensure(id, body): Promise> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body ?? {}), + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Workspace request failed", + }; + } + }, - async get(id): Promise { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); - if (res.status === 404 || !res.ok) return null; - return (await res.json()) as WorkspaceResponse; - } catch { - return null; - } - }, + async get(id): Promise { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); + if (res.status === 404 || !res.ok) return null; + return (await res.json()) as WorkspaceResponse; + } catch { + return null; + } + }, - async setTitle(id, title): Promise> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest), - }); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Rename failed" }; - } - }, + async setTitle(id, title): Promise> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest), + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Rename failed" }; + } + }, - async setDefaultCwd(id, defaultCwd): Promise> { - try { - const res = await fetchImpl( - `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest), - }, - ); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" }; - } - }, + async setDefaultCwd(id, defaultCwd): Promise> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" }; + } + }, - async setDefaultComputer(id, computerId): Promise> { - try { - const res = await fetchImpl( - `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest), - }, - ); - if (!res.ok) return { ok: false, error: await errText(res) }; - return { ok: true, value: (await res.json()) as WorkspaceResponse }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : "Set default computer failed", - }; - } - }, + async setDefaultComputer(id, computerId): Promise> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest), + }, + ); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set default computer failed", + }; + } + }, - async delete(id): Promise> { - try { - const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { - method: "DELETE", - }); - if (!res.ok) return { ok: false, error: await errText(res) }; - const data = (await res.json()) as DeleteWorkspaceResponse; - return { ok: true, value: { closedCount: data.closedCount } }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : "Delete failed" }; - } - }, - }; + async delete(id): Promise> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + const data = (await res.json()) as DeleteWorkspaceResponse; + return { ok: true, value: { closedCount: data.closedCount } }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Delete failed" }; + } + }, + }; } diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index 6cc86a3..177e49b 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -2,11 +2,11 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; export type { Route } from "./logic/route"; export { - DEFAULT_WORKSPACE_ID, - isValidSlug, - parsePath, - WORKSPACE_SLUG_RE, - workspacePath, + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, } from "./logic/route"; export { pageTitle, relativeTime } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; @@ -16,6 +16,6 @@ export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "workspaces", - description: "URL-driven conversation grouping with a backend-owned default cwd", + name: "workspaces", + description: "URL-driven conversation grouping with a backend-owned default cwd", } as const; diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts index a6da3e1..96e0ff3 100644 --- a/src/features/workspaces/logic/route.test.ts +++ b/src/features/workspaces/logic/route.test.ts @@ -1,77 +1,77 @@ import { describe, expect, it } from "vitest"; import { - DEFAULT_WORKSPACE_ID, - isValidSlug, - parsePath, - WORKSPACE_SLUG_RE, - workspacePath, + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, } from "./route"; describe("parsePath", () => { - it("treats the root path as home", () => { - expect(parsePath("/")).toEqual({ kind: "home" }); - expect(parsePath("")).toEqual({ kind: "home" }); - }); + it("treats the root path as home", () => { + expect(parsePath("/")).toEqual({ kind: "home" }); + expect(parsePath("")).toEqual({ kind: "home" }); + }); - it("trims surrounding slashes", () => { - expect(parsePath("//")).toEqual({ kind: "home" }); - expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" }); - }); + it("trims surrounding slashes", () => { + expect(parsePath("//")).toEqual({ kind: "home" }); + expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" }); + }); - it("parses a single segment as a workspace id", () => { - expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" }); - expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" }); - expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" }); - }); + it("parses a single segment as a workspace id", () => { + expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" }); + expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" }); + expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" }); + }); - it("takes only the first segment of a deeper path", () => { - expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" }); - expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" }); - }); + it("takes only the first segment of a deeper path", () => { + expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" }); + expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" }); + }); - it("URL-decodes the segment", () => { - expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" }); - }); + it("URL-decodes the segment", () => { + expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" }); + }); - it("does not validate the slug — an invalid id is still a workspace route", () => { - expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" }); - expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" }); - }); + it("does not validate the slug — an invalid id is still a workspace route", () => { + expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" }); + expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" }); + }); }); describe("isValidSlug", () => { - it("accepts lowercase alphanumeric + internal hyphens", () => { - expect(isValidSlug("default")).toBe(true); - expect(isValidSlug("my-workspace")).toBe(true); - expect(isValidSlug("a")).toBe(true); - expect(isValidSlug("ws-1")).toBe(true); - }); + it("accepts lowercase alphanumeric + internal hyphens", () => { + expect(isValidSlug("default")).toBe(true); + expect(isValidSlug("my-workspace")).toBe(true); + expect(isValidSlug("a")).toBe(true); + expect(isValidSlug("ws-1")).toBe(true); + }); - it("accepts up to 40 chars", () => { - expect(isValidSlug("a".repeat(40))).toBe(true); - }); + it("accepts up to 40 chars", () => { + expect(isValidSlug("a".repeat(40))).toBe(true); + }); - it("rejects empty and too-long", () => { - expect(isValidSlug("")).toBe(false); - expect(isValidSlug("a".repeat(41))).toBe(false); - }); + it("rejects empty and too-long", () => { + expect(isValidSlug("")).toBe(false); + expect(isValidSlug("a".repeat(41))).toBe(false); + }); - it("rejects uppercase, spaces, and leading/trailing hyphens", () => { - expect(isValidSlug("MyWS")).toBe(false); - expect(isValidSlug("has space")).toBe(false); - expect(isValidSlug("-leading")).toBe(false); - expect(isValidSlug("trailing-")).toBe(false); - expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex - }); + it("rejects uppercase, spaces, and leading/trailing hyphens", () => { + expect(isValidSlug("MyWS")).toBe(false); + expect(isValidSlug("has space")).toBe(false); + expect(isValidSlug("-leading")).toBe(false); + expect(isValidSlug("trailing-")).toBe(false); + expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex + }); - it("WORKSPACE_SLUG_RE matches the default id", () => { - expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true); - }); + it("WORKSPACE_SLUG_RE matches the default id", () => { + expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true); + }); }); describe("workspacePath", () => { - it("builds the URL path for a workspace id", () => { - expect(workspacePath("default")).toBe("/default"); - expect(workspacePath("my-ws")).toBe("/my-ws"); - }); + it("builds the URL path for a workspace id", () => { + expect(workspacePath("default")).toBe("/default"); + expect(workspacePath("my-ws")).toBe("/my-ws"); + }); }); diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts index e30dc16..015c6d6 100644 --- a/src/features/workspaces/logic/route.ts +++ b/src/features/workspaces/logic/route.ts @@ -30,12 +30,12 @@ export const DEFAULT_WORKSPACE_ID = "default"; * surfaces the error). */ export function parsePath(pathname: string): Route { - const trimmed = pathname.replace(/^\/+|\/+$/g, ""); - if (trimmed === "") return { kind: "home" }; - const first = trimmed.split("/")[0] ?? ""; - const id = safeDecode(first); - if (id === "") return { kind: "home" }; - return { kind: "workspace", id }; + const trimmed = pathname.replace(/^\/+|\/+$/g, ""); + if (trimmed === "") return { kind: "home" }; + const first = trimmed.split("/")[0] ?? ""; + const id = safeDecode(first); + if (id === "") return { kind: "home" }; + return { kind: "workspace", id }; } /** @@ -43,7 +43,7 @@ export function parsePath(pathname: string): Route { * Used by the home view's "new workspace" input before navigating. */ export function isValidSlug(slug: string): boolean { - return WORKSPACE_SLUG_RE.test(slug); + return WORKSPACE_SLUG_RE.test(slug); } /** @@ -51,13 +51,13 @@ export function isValidSlug(slug: string): boolean { * workspace. Pure: id in, path string out. */ export function workspacePath(id: string): string { - return `/${id}`; + return `/${id}`; } function safeDecode(segment: string): string { - try { - return decodeURIComponent(segment); - } catch { - return segment; - } + try { + return decodeURIComponent(segment); + } catch { + return segment; + } } diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index a4ed19c..86342e7 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -3,66 +3,66 @@ import { describe, expect, it } from "vitest"; import { pageTitle, relativeTime } from "./view-model"; describe("relativeTime", () => { - const now = 1_000_000_000_000; // 2001-09-09 + const now = 1_000_000_000_000; // 2001-09-09 - it("is 'now' within a minute", () => { - expect(relativeTime(now, now)).toBe("now"); - expect(relativeTime(now - 59_000, now)).toBe("now"); - }); + it("is 'now' within a minute", () => { + expect(relativeTime(now, now)).toBe("now"); + expect(relativeTime(now - 59_000, now)).toBe("now"); + }); - it("is minutes under an hour", () => { - expect(relativeTime(now - 5 * 60_000, now)).toBe("5m"); - expect(relativeTime(now - 59 * 60_000, now)).toBe("59m"); - }); + it("is minutes under an hour", () => { + expect(relativeTime(now - 5 * 60_000, now)).toBe("5m"); + expect(relativeTime(now - 59 * 60_000, now)).toBe("59m"); + }); - it("is hours under a day", () => { - expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h"); - }); + it("is hours under a day", () => { + expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h"); + }); - it("is days under a week", () => { - expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); - }); + it("is days under a week", () => { + expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); + }); - it("is a short date beyond a week", () => { - // 7+ days ago: just check it is a MM/DD string. - const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); - expect(s).toMatch(/^\d{2}\/\d{2}$/); - }); + it("is a short date beyond a week", () => { + // 7+ days ago: just check it is a MM/DD string. + const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); + expect(s).toMatch(/^\d{2}\/\d{2}$/); + }); }); describe("pageTitle", () => { - // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed). - const ws = (id: string, title: string): WorkspaceEntry => ({ - id, - title, - defaultCwd: null, - defaultComputerId: null, - createdAt: 0, - lastActivityAt: 0, - conversationCount: 0, - }); + // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed). + const ws = (id: string, title: string): WorkspaceEntry => ({ + id, + title, + defaultCwd: null, + defaultComputerId: null, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); - it("is 'Dispatch' for the home route", () => { - expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch"); - expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch"); - }); + it("is 'Dispatch' for the home route", () => { + expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch"); + expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch"); + }); - it("is 'Dispatch: {title}' for a workspace with a display title", () => { - const list = [ws("default", "Default"), ws("my-ws", "My Workspace")]; - expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace"); - }); + it("is 'Dispatch: {title}' for a workspace with a display title", () => { + const list = [ws("default", "Default"), ws("my-ws", "My Workspace")]; + expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace"); + }); - it("falls back to the slug (id) until the list has loaded the workspace", () => { - expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending"); - }); + it("falls back to the slug (id) until the list has loaded the workspace", () => { + expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending"); + }); - it("uses the id as the title when it was never customized (defaults to id)", () => { - const list = [ws("default", "default")]; - expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default"); - }); + it("uses the id as the title when it was never customized (defaults to id)", () => { + const list = [ws("default", "default")]; + expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default"); + }); - it("matches by id, not title", () => { - const list = [ws("a", "shared-title"), ws("b", "shared-title")]; - expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); - }); + it("matches by id, not title", () => { + const list = [ws("a", "shared-title"), ws("b", "shared-title")]; + expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); + }); }); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index e904514..6dd64c6 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -14,9 +14,9 @@ import type { Route } from "./route"; * workspaces in, string out. */ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string { - if (route.kind === "home") return "Dispatch"; - const ws = workspaces.find((w) => w.id === route.id); - return `Dispatch: ${ws?.title ?? route.id}`; + if (route.kind === "home") return "Dispatch"; + const ws = workspaces.find((w) => w.id === route.id); + return `Dispatch: ${ws?.title ?? route.id}`; } /** @@ -25,17 +25,17 @@ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): * (a workspace just created) read as "now". */ export function relativeTime(then: number, now: number): string { - const diff = now - then; - if (diff < 60_000) return "now"; - const mins = Math.floor(diff / 60_000); - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d`; - // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. - const d = new Date(then); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${month}/${day}`; + const diff = now - then; + if (diff < 60_000) return "now"; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. + const d = new Date(then); + const month = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${month}/${day}`; } diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 0cf4ce2..046c235 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -9,80 +9,80 @@ import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; * owned by the composition root. */ export interface WorkspaceStore { - /** All workspaces (sorted by lastActivityAt desc by the backend). */ - readonly list: readonly WorkspaceEntry[]; - readonly loading: boolean; - readonly error: string | null; - /** Refresh the list from the backend. */ - refresh(): Promise; - /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ - ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; - /** Rename a workspace (display only; id unchanged). */ - rename(id: string, title: string): Promise>; - /** Set/clear a workspace's default cwd. */ - setDefaultCwd(id: string, defaultCwd: string | null): Promise>; - /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ - setDefaultComputer(id: string, computerId: string | null): Promise>; - /** Delete a workspace (closes its conversations, reassigns to "default"). */ - remove(id: string): Promise>; + /** All workspaces (sorted by lastActivityAt desc by the backend). */ + readonly list: readonly WorkspaceEntry[]; + readonly loading: boolean; + readonly error: string | null; + /** Refresh the list from the backend. */ + refresh(): Promise; + /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ + ensure(id: string, body?: EnsureWorkspaceRequest): Promise>; + /** Rename a workspace (display only; id unchanged). */ + rename(id: string, title: string): Promise>; + /** Set/clear a workspace's default cwd. */ + setDefaultCwd(id: string, defaultCwd: string | null): Promise>; + /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */ + setDefaultComputer(id: string, computerId: string | null): Promise>; + /** Delete a workspace (closes its conversations, reassigns to "default"). */ + remove(id: string): Promise>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { - let list = $state([]); - let loading = $state(false); - let error = $state(null); + let list = $state([]); + let loading = $state(false); + let error = $state(null); - return { - get list(): readonly WorkspaceEntry[] { - return list; - }, - get loading(): boolean { - return loading; - }, - get error(): string | null { - return error; - }, + return { + get list(): readonly WorkspaceEntry[] { + return list; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, - async refresh(): Promise { - loading = true; - error = null; - try { - list = await http.list(); - } catch (err) { - error = err instanceof Error ? err.message : "Failed to load workspaces"; - } finally { - loading = false; - } - }, + async refresh(): Promise { + loading = true; + error = null; + try { + list = await http.list(); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load workspaces"; + } finally { + loading = false; + } + }, - async ensure(id, body): Promise> { - const result = await http.ensure(id, body); - if (result.ok) void this.refresh(); - return result; - }, + async ensure(id, body): Promise> { + const result = await http.ensure(id, body); + if (result.ok) void this.refresh(); + return result; + }, - async rename(id, title): Promise> { - const result = await http.setTitle(id, title); - if (result.ok) void this.refresh(); - return result; - }, + async rename(id, title): Promise> { + const result = await http.setTitle(id, title); + if (result.ok) void this.refresh(); + return result; + }, - async setDefaultCwd(id, defaultCwd): Promise> { - const result = await http.setDefaultCwd(id, defaultCwd); - if (result.ok) void this.refresh(); - return result; - }, + async setDefaultCwd(id, defaultCwd): Promise> { + const result = await http.setDefaultCwd(id, defaultCwd); + if (result.ok) void this.refresh(); + return result; + }, - async setDefaultComputer(id, computerId): Promise> { - const result = await http.setDefaultComputer(id, computerId); - if (result.ok) void this.refresh(); - return result; - }, + async setDefaultComputer(id, computerId): Promise> { + const result = await http.setDefaultComputer(id, computerId); + if (result.ok) void this.refresh(); + return result; + }, - async remove(id): Promise> { - const result = await http.delete(id); - if (result.ok) void this.refresh(); - return result; - }, - }; + async remove(id): Promise> { + const result = await http.delete(id); + if (result.ok) void this.refresh(); + return result; + }, + }; } diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte index ff8a8ea..81b89e9 100644 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ b/src/features/workspaces/ui/WorkspaceCard.svelte @@ -1,212 +1,205 @@
  • -
    - {#if editingTitle} - { - if (e.key === "Enter") saveTitle(); - else if (e.key === "Escape") cancelTitle(); - }} - onblur={saveTitle} - /> - {:else} - - {ws.title} - {/if} - /{ws.id} - - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} - · {relativeTime(ws.lastActivityAt, Date.now())} - - -
    - - {#if titleError} -

    {titleError}

    - {/if} - -
    - cwd - { - if (e.key === "Enter") saveCwd(); - }} - /> - -
    - -
    - ssh - - {#if savingComputer} - - {/if} -
    - - - - {#if cwdError} -

    {cwdError}

    - {:else if !cwdDirty && !ws.defaultCwd} -

    No default cwd set — conversations inherit the server default.

    - {/if} - - {#if computerError} -

    {computerError}

    - {:else if !ws.defaultComputerId} -

    No default computer — conversations run locally (no SSH).

    - {/if} +
    + {#if editingTitle} + { + if (e.key === "Enter") saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={saveTitle} + /> + {:else} + + {ws.title} + {/if} + /{ws.id} + + {ws.conversationCount} + {ws.conversationCount === 1 ? "conversation" : "conversations"} + · {relativeTime(ws.lastActivityAt, Date.now())} + + +
    + + {#if titleError} +

    {titleError}

    + {/if} + +
    + cwd + { + if (e.key === "Enter") saveCwd(); + }} + /> + +
    + +
    + ssh + + {#if savingComputer} + + {/if} +
    + +
    + Open +
    + + {#if cwdError} +

    {cwdError}

    + {:else if !cwdDirty && !ws.defaultCwd} +

    No default cwd set — conversations inherit the server default.

    + {/if} + + {#if computerError} +

    {computerError}

    + {:else if !ws.defaultComputerId} +

    No default computer — conversations run locally (no SSH).

    + {/if}
  • diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts index f6ea432..0736efb 100644 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ b/src/features/workspaces/ui/WorkspaceCard.test.ts @@ -7,128 +7,128 @@ import type { WorkspaceStore } from "../store.svelte"; import WorkspaceCard from "./WorkspaceCard.svelte"; function fakeEntry(overrides: Partial = {}): WorkspaceEntry { - return { - id: "my-ws", - title: "My Workspace", - defaultCwd: null, - defaultComputerId: null, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - ...overrides, - }; + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 3, + ...overrides, + }; } /** A fake store that records calls + resolves ok. */ function fakeStore() { - return { - rename: vi.fn( - async (id: string, _title: string): Promise> => ({ - ok: true, - value: fakeEntry({ id, title: _title }), - }), - ), - setDefaultCwd: vi.fn( - async (id: string, defaultCwd: string | null): Promise> => ({ - ok: true, - value: fakeEntry({ id, defaultCwd }), - }), - ), - setDefaultComputer: vi.fn( - async (id: string, computerId: string | null): Promise> => ({ - ok: true, - value: fakeEntry({ id, defaultComputerId: computerId }), - }), - ), - remove: vi.fn( - async (): Promise> => ({ - ok: true, - value: { closedCount: 0 }, - }), - ), - }; + return { + rename: vi.fn( + async (id: string, _title: string): Promise> => ({ + ok: true, + value: fakeEntry({ id, title: _title }), + }), + ), + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async (id: string, computerId: string | null): Promise> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId: computerId }), + }), + ), + remove: vi.fn( + async (): Promise> => ({ + ok: true, + value: { closedCount: 0 }, + }), + ), + }; } describe("WorkspaceCard", () => { - it("renders the title, slug, and an Open link", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate, computers: [] }, - }); - expect(screen.getByText("My Workspace")).toBeInTheDocument(); - expect(screen.getByText("/my-ws")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws"); - }); - - it("double-clicking the title reveals an edit input", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); - }); - - it("renames via the store on Enter", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - const input = screen.getByLabelText("Workspace title"); - await user.clear(input); - await user.type(input, "Renamed{Enter}"); - - expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); - }); - - it("enables Set only when the cwd differs, then saves it", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - expect(input).toHaveValue("/old"); - expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); - - await user.clear(input); - await user.type(input, "/new/path"); - expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); - - await user.click(screen.getByRole("button", { name: "Set" })); - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path"); - }); - - it("clears the cwd to null when saved empty (inherits the server default)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - await user.clear(input); - await user.click(screen.getByRole("button", { name: "Set" })); - - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); - }); - - it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => { - 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. - expect(open).toHaveAttribute("href", "/my-ws"); - expect(open).toHaveAttribute("target", "_blank"); - expect(open.getAttribute("rel") ?? "").toMatch(/noopener/); - }); + it("renders the title, slug, and an Open link", () => { + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws"); + }); + + it("double-clicking the title reveals an edit input", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("renames via the store on Enter", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + const input = screen.getByLabelText("Workspace title"); + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("enables Set only when the cwd differs, then saves it", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, + }); + + const input = screen.getByLabelText("Default working directory"); + expect(input).toHaveValue("/old"); + expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); + + await user.clear(input); + await user.type(input, "/new/path"); + expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); + + await user.click(screen.getByRole("button", { name: "Set" })); + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path"); + }); + + it("clears the cwd to null when saved empty (inherits the server default)", async () => { + const user = userEvent.setup(); + const store = fakeStore() as unknown as WorkspaceStore; + render(WorkspaceCard, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, + }); + + const input = screen.getByLabelText("Default working directory"); + await user.clear(input); + await user.click(screen.getByRole("button", { name: "Set" })); + + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); + }); + + it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => { + 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. + expect(open).toHaveAttribute("href", "/my-ws"); + expect(open).toHaveAttribute("target", "_blank"); + expect(open.getAttribute("rel") ?? "").toMatch(/noopener/); + }); }); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index bdfd023..02e92b4 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,92 +1,97 @@
    -
    -

    Workspaces

    - { - e.preventDefault(); - onNavigate("/default"); - }} - > - Default - -
    +
    +

    Workspaces

    + { + e.preventDefault(); + onNavigate("/default"); + }} + > + Default + +
    -
    { - e.preventDefault(); - createWorkspace(); - }} - > -
    - - -
    - - - {#if slugError} -

    {slugError}

    - {/if} +
    { + e.preventDefault(); + createWorkspace(); + }} + > +
    + + +
    + + + {#if slugError} +

    {slugError}

    + {/if} -
    - {#if store.loading && store.list.length === 0} -
    - -
    - {:else if store.list.length === 0} -

    - No workspaces yet. Create one above or visit /your-name in the URL. -

    - {:else} -
      - {#each store.list as ws (ws.id)} - - {/each} -
    - {/if} -
    +
    + {#if store.loading && store.list.length === 0} +
    + +
    + {:else if store.list.length === 0} +

    + No workspaces yet. Create one above or visit /your-name in the URL. +

    + {:else} +
      + {#each store.list as ws (ws.id)} + + {/each} +
    + {/if} +
    diff --git a/src/main.ts b/src/main.ts index 9ebef3a..565c0d6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,7 @@ import "./app.css"; const target = document.getElementById("app"); if (!target) { - throw new Error("missing #app mount target"); + throw new Error("missing #app mount target"); } export default mount(App, { target }); -- cgit v1.2.3