summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
-rw-r--r--.prettierrc.json15
-rw-r--r--AGENTS.md2
-rw-r--r--biome.json32
-rw-r--r--bun.lock6
-rw-r--r--package.json92
-rw-r--r--scripts/live-probe-provider-retry.ts276
-rw-r--r--scripts/live-probe.ts600
-rw-r--r--scripts/probe-cache-warming.ts414
-rw-r--r--src/App.svelte122
-rw-r--r--src/adapters/history/index.test.ts158
-rw-r--r--src/adapters/history/index.ts116
-rw-r--r--src/adapters/idb/index.test.ts160
-rw-r--r--src/adapters/idb/index.ts292
-rw-r--r--src/adapters/local-storage/index.test.ts224
-rw-r--r--src/adapters/local-storage/index.ts86
-rw-r--r--src/adapters/ws/index.test.ts790
-rw-r--r--src/adapters/ws/index.ts198
-rw-r--r--src/adapters/ws/logic.test.ts742
-rw-r--r--src/adapters/ws/logic.ts286
-rw-r--r--src/app.css204
-rw-r--r--src/app/App.svelte1199
-rw-r--r--src/app/App.test.ts984
-rw-r--r--src/app/ErrorModal.svelte271
-rw-r--r--src/app/resolve-http-url.test.ts102
-rw-r--r--src/app/resolve-http-url.ts30
-rw-r--r--src/app/resolve-ws-url.test.ts96
-rw-r--r--src/app/resolve-ws-url.ts28
-rw-r--r--src/app/store.svelte.ts2918
-rw-r--r--src/app/store.test.ts2174
-rw-r--r--src/app/uuid.test.ts44
-rw-r--r--src/app/uuid.ts92
-rw-r--r--src/components/Table.svelte74
-rw-r--r--src/components/Table.test.ts54
-rw-r--r--src/core/chunks/groups.test.ts204
-rw-r--r--src/core/chunks/groups.ts118
-rw-r--r--src/core/chunks/index.ts38
-rw-r--r--src/core/chunks/reducer.test.ts1546
-rw-r--r--src/core/chunks/reducer.ts488
-rw-r--r--src/core/chunks/retry-banner.test.ts102
-rw-r--r--src/core/chunks/retry-banner.ts38
-rw-r--r--src/core/chunks/selectors.ts72
-rw-r--r--src/core/chunks/trim.test.ts404
-rw-r--r--src/core/chunks/trim.ts160
-rw-r--r--src/core/chunks/types.ts120
-rw-r--r--src/core/metrics/format.test.ts658
-rw-r--r--src/core/metrics/format.ts158
-rw-r--r--src/core/metrics/index.ts48
-rw-r--r--src/core/metrics/place.test.ts976
-rw-r--r--src/core/metrics/place.ts448
-rw-r--r--src/core/metrics/reducer.test.ts822
-rw-r--r--src/core/metrics/reducer.ts362
-rw-r--r--src/core/metrics/types.ts114
-rw-r--r--src/core/protocol/index.ts12
-rw-r--r--src/core/protocol/reducer.test.ts424
-rw-r--r--src/core/protocol/reducer.ts154
-rw-r--r--src/core/protocol/types.ts28
-rw-r--r--src/core/wire/conformance.test.ts424
-rw-r--r--src/core/wire/conformance.ts180
-rw-r--r--src/core/wire/index.ts8
-rw-r--r--src/features/cache-warming/index.ts4
-rw-r--r--src/features/cache-warming/logic/view-model.test.ts406
-rw-r--r--src/features/cache-warming/logic/view-model.ts244
-rw-r--r--src/features/cache-warming/ui/CacheWarmingView.svelte468
-rw-r--r--src/features/chat/index.ts28
-rw-r--r--src/features/chat/model-select.test.ts88
-rw-r--r--src/features/chat/model-select.ts44
-rw-r--r--src/features/chat/ports.ts24
-rw-r--r--src/features/chat/reasoning-effort.test.ts68
-rw-r--r--src/features/chat/reasoning-effort.ts32
-rw-r--r--src/features/chat/store.svelte.ts724
-rw-r--r--src/features/chat/store.test.ts3060
-rw-r--r--src/features/chat/test-helpers.ts228
-rw-r--r--src/features/chat/ui.test.ts1526
-rw-r--r--src/features/chat/ui/ChatView.svelte611
-rw-r--r--src/features/chat/ui/CompactionView.svelte278
-rw-r--r--src/features/chat/ui/Composer.svelte358
-rw-r--r--src/features/chat/ui/ModelSelector.svelte82
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte130
-rw-r--r--src/features/computer/index.ts42
-rw-r--r--src/features/computer/logic/view-model.test.ts266
-rw-r--r--src/features/computer/logic/view-model.ts170
-rw-r--r--src/features/computer/ui/ComputerField.svelte410
-rw-r--r--src/features/computer/ui/ComputerSelect.svelte60
-rw-r--r--src/features/conversation-cache/cache.test.ts326
-rw-r--r--src/features/conversation-cache/cache.ts98
-rw-r--r--src/features/conversation-cache/index.ts10
-rw-r--r--src/features/conversation-cache/logic.test.ts248
-rw-r--r--src/features/conversation-cache/logic.ts82
-rw-r--r--src/features/conversation-cache/types.ts38
-rw-r--r--src/features/cwd-lsp/index.ts12
-rw-r--r--src/features/cwd-lsp/logic/view-model.test.ts154
-rw-r--r--src/features/cwd-lsp/logic/view-model.ts140
-rw-r--r--src/features/cwd-lsp/ui/CwdField.svelte170
-rw-r--r--src/features/cwd-lsp/ui/LspStatusView.svelte234
-rw-r--r--src/features/markdown/index.ts4
-rw-r--r--src/features/markdown/logic/markdown.test.ts106
-rw-r--r--src/features/markdown/logic/markdown.ts174
-rw-r--r--src/features/markdown/ui/Markdown.svelte106
-rw-r--r--src/features/markdown/ui/markdown.test.ts52
-rw-r--r--src/features/mcp/index.ts4
-rw-r--r--src/features/mcp/logic/view-model.test.ts136
-rw-r--r--src/features/mcp/logic/view-model.ts128
-rw-r--r--src/features/mcp/ui/McpStatusView.svelte242
-rw-r--r--src/features/settings/index.ts4
-rw-r--r--src/features/settings/logic/view-model.test.ts104
-rw-r--r--src/features/settings/logic/view-model.ts32
-rw-r--r--src/features/settings/ui/ChatLimitField.svelte186
-rw-r--r--src/features/settings/ui/ChatLimitField.test.ts128
-rw-r--r--src/features/smart-scroll/index.ts28
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.test.ts156
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts56
-rw-r--r--src/features/smart-scroll/ui/ScrollToBottom.svelte60
-rw-r--r--src/features/smart-scroll/ui/controller.svelte.ts242
-rw-r--r--src/features/smart-scroll/ui/controller.test.ts296
-rw-r--r--src/features/surface-host/index.ts4
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts72
-rw-r--r--src/features/surface-host/logic/message-queue.ts38
-rw-r--r--src/features/surface-host/logic/plan.test.ts468
-rw-r--r--src/features/surface-host/logic/plan.ts210
-rw-r--r--src/features/surface-host/logic/table.test.ts78
-rw-r--r--src/features/surface-host/logic/table.ts66
-rw-r--r--src/features/surface-host/logic/todo.test.ts110
-rw-r--r--src/features/surface-host/logic/todo.ts32
-rw-r--r--src/features/surface-host/logic/types.ts78
-rw-r--r--src/features/surface-host/ui/Button.svelte30
-rw-r--r--src/features/surface-host/ui/MessageQueueList.svelte30
-rw-r--r--src/features/surface-host/ui/Number.svelte72
-rw-r--r--src/features/surface-host/ui/Progress.svelte12
-rw-r--r--src/features/surface-host/ui/Selector.svelte50
-rw-r--r--src/features/surface-host/ui/StatTable.svelte30
-rw-r--r--src/features/surface-host/ui/SurfaceTable.svelte14
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte87
-rw-r--r--src/features/surface-host/ui/TodoList.svelte106
-rw-r--r--src/features/surface-host/ui/Toggle.svelte36
-rw-r--r--src/features/system-prompt/index.ts18
-rw-r--r--src/features/system-prompt/logic/view-model.test.ts148
-rw-r--r--src/features/system-prompt/logic/view-model.ts74
-rw-r--r--src/features/system-prompt/ui/SystemPromptBuilder.svelte467
-rw-r--r--src/features/tabs/index.ts26
-rw-r--r--src/features/tabs/tabs-store.svelte.ts118
-rw-r--r--src/features/tabs/tabs-store.test.ts282
-rw-r--r--src/features/tabs/tabs.test.ts418
-rw-r--r--src/features/tabs/tabs.ts120
-rw-r--r--src/features/tabs/ui.test.ts404
-rw-r--r--src/features/tabs/ui/TabBar.svelte325
-rw-r--r--src/features/views/index.ts16
-rw-r--r--src/features/views/logic/panels.test.ts88
-rw-r--r--src/features/views/logic/panels.ts34
-rw-r--r--src/features/views/ui/ViewSidebar.svelte170
-rw-r--r--src/features/views/ui/ViewSidebar.test.ts76
-rw-r--r--src/features/workspaces/adapter/http.test.ts224
-rw-r--r--src/features/workspaces/adapter/http.ts242
-rw-r--r--src/features/workspaces/index.ts14
-rw-r--r--src/features/workspaces/logic/route.test.ts112
-rw-r--r--src/features/workspaces/logic/route.ts26
-rw-r--r--src/features/workspaces/logic/view-model.test.ts98
-rw-r--r--src/features/workspaces/logic/view-model.ts32
-rw-r--r--src/features/workspaces/store.svelte.ts132
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte407
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts234
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte165
-rw-r--r--src/main.ts2
-rw-r--r--svelte.config.js2
-rw-r--r--tsconfig.json32
-rw-r--r--vite.config.ts48
-rw-r--r--vitest-setup.ts12
166 files changed, 20184 insertions, 20099 deletions
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..aacc6cf
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,15 @@
+{
+ "plugins": ["prettier-plugin-svelte"],
+ "useTabs": false,
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "overrides": [
+ {
+ "files": "*.svelte",
+ "options": { "parser": "svelte" }
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
index 9a62d02..dabd440 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,7 +23,7 @@ effects / no ambient state / typed contracts / asymmetric testing.
## Stack
Bun + Vite + Svelte 5 (runes) + TypeScript (strict). Biome for lint/format
-(tabs, double quotes, semicolons, width 100) — **biome covers `.ts`/`.js` ONLY;
+(2-space indent, double quotes, semicolons, width 100) — **biome covers `.ts`/`.js` ONLY;
`.svelte` correctness is `svelte-check`'s job** (biome can't read Svelte template
semantics — it flags template-used vars as unused). Vitest + `@testing-library/
svelte` for tests.
diff --git a/biome.json b/biome.json
index 129f686..528c1a9 100644
--- a/biome.json
+++ b/biome.json
@@ -1,18 +1,18 @@
{
- "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
- "assist": { "actions": { "source": { "organizeImports": "on" } } },
- "linter": { "enabled": true, "rules": { "recommended": true } },
- "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 100 },
- "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } },
- "css": { "parser": { "tailwindDirectives": true } },
- "files": {
- "includes": [
- "**",
- "!**/node_modules",
- "!**/dist",
- "!**/build",
- "!**/*.svelte",
- "!**/src/themes"
- ]
- }
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
+ "assist": { "actions": { "source": { "organizeImports": "on" } } },
+ "linter": { "enabled": true, "rules": { "recommended": true } },
+ "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
+ "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always" } },
+ "css": { "parser": { "tailwindDirectives": true } },
+ "files": {
+ "includes": [
+ "**",
+ "!**/node_modules",
+ "!**/dist",
+ "!**/build",
+ "!**/*.svelte",
+ "!**/src/themes"
+ ]
+ }
}
diff --git a/bun.lock b/bun.lock
index a13b57a..373ae5b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -24,6 +24,8 @@
"daisyui": "^5.5.20",
"fake-indexeddb": "^6.0.0",
"jsdom": "^25.0.0",
+ "prettier": "^3.8.5",
+ "prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.3.0",
@@ -466,6 +468,10 @@
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
+ "prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-zxcTTCedNGJM4R8sj/Cq/F0W/c4iE0afWBcBwMTRtw4WHYP9TWkYjdiH3npPRUYsXQCPR0hTU9yjovOu+E6EQA=="],
+
+ "prettier-plugin-svelte": ["[email protected]", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^5.0.0" } }, "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA=="],
+
"pretty-format": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"punycode": ["[email protected]", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
diff --git a/package.json b/package.json
index dbd57c8..ea6c751 100644
--- a/package.json
+++ b/package.json
@@ -1,47 +1,49 @@
{
- "name": "dispatch-web",
- "version": "0.0.0",
- "private": true,
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "typecheck": "svelte-check --tsconfig ./tsconfig.json",
- "test": "vitest run --passWithNoTests",
- "test:watch": "vitest",
- "check": "biome check .",
- "check:fix": "biome check --write ."
- },
- "dependencies": {
- "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract",
- "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
- "@dispatch/wire": "file:../dispatch-backend/packages/wire",
- "dompurify": "^3.4.5",
- "highlight.js": "^11.11.1",
- "marked": "^18.0.4",
- "marked-highlight": "^2.2.4"
- },
- "overrides": {
- "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
- "@dispatch/wire": "file:../dispatch-backend/packages/wire"
- },
- "devDependencies": {
- "@biomejs/biome": "^2.4.16",
- "@sveltejs/vite-plugin-svelte": "^5.0.0",
- "@tailwindcss/vite": "^4.3.0",
- "@testing-library/jest-dom": "^6.6.0",
- "@testing-library/svelte": "^5.2.0",
- "@testing-library/user-event": "^14.6.1",
- "@tsconfig/svelte": "^5.0.0",
- "daisyui": "^5.5.20",
- "fake-indexeddb": "^6.0.0",
- "jsdom": "^25.0.0",
- "svelte": "^5.0.0",
- "svelte-check": "^4.0.0",
- "tailwindcss": "^4.3.0",
- "typescript": "^5.7.0",
- "vite": "^6.0.0",
- "vitest": "^3.0.0"
- }
+ "name": "dispatch-web",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json",
+ "test": "vitest run --passWithNoTests",
+ "test:watch": "vitest",
+ "check": "biome check .",
+ "check:fix": "biome check --write ."
+ },
+ "dependencies": {
+ "@dispatch/transport-contract": "file:../dispatch-backend/packages/transport-contract",
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire",
+ "dompurify": "^3.4.5",
+ "highlight.js": "^11.11.1",
+ "marked": "^18.0.4",
+ "marked-highlight": "^2.2.4"
+ },
+ "overrides": {
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.16",
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@tailwindcss/vite": "^4.3.0",
+ "@testing-library/jest-dom": "^6.6.0",
+ "@testing-library/svelte": "^5.2.0",
+ "@testing-library/user-event": "^14.6.1",
+ "@tsconfig/svelte": "^5.0.0",
+ "daisyui": "^5.5.20",
+ "fake-indexeddb": "^6.0.0",
+ "jsdom": "^25.0.0",
+ "prettier": "^3.8.5",
+ "prettier-plugin-svelte": "^4.1.1",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "tailwindcss": "^4.3.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0",
+ "vitest": "^3.0.0"
+ }
}
diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts
index 22f1794..952163c 100644
--- a/scripts/live-probe-provider-retry.ts
+++ b/scripts/live-probe-provider-retry.ts
@@ -26,10 +26,10 @@ import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
import { parseServerMessage } from "../src/adapters/ws/logic.ts";
import {
- foldEvent,
- initialState,
- selectChunks,
- selectProviderRetry,
+ foldEvent,
+ initialState,
+ selectChunks,
+ selectProviderRetry,
} from "../src/core/chunks/index.ts";
const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
@@ -40,149 +40,149 @@ type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
const checks: { name: string; ok: boolean; detail?: string }[] = [];
const record = (name: string, ok: boolean, detail?: string) => {
- checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
- console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
+ checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
};
const fail = (msg: string): never => {
- console.error(`\n[probe] FATAL: ${msg}`);
- process.exit(1);
+ console.error(`\n[probe] FATAL: ${msg}`);
+ process.exit(1);
};
/** A chat.delta JSON frame carrying the given AgentEvent, exactly as the backend sends. */
function deltaFrame(event: ChatDeltaMessage["event"]): string {
- return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage);
+ return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage);
}
async function main() {
- console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`);
-
- // ─── 1. REGRESSION: a real text turn through the updated foldEvent ──────────
- // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts).
- const handlers = new Map<string, (msg: ChatMsg) => void>();
- const socket = createSurfaceSocket({
- url: WS_URL,
- onMessage: (_m: SurfaceServerMessage) => {},
- onChat: (msg: ChatMsg) => {
- const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
- const h = id !== undefined ? handlers.get(id) : undefined;
- h?.(msg);
- },
- });
- await new Promise((r) => setTimeout(r, 500));
-
- const conversationId = crypto.randomUUID();
- let state = initialState();
- let deltas = 0;
- let sealed = false;
- let error: string | null = null;
- const done = Promise.withResolvers<void>();
- handlers.set(conversationId, (msg) => {
- if (msg.type === "chat.error") {
- error = msg.message;
- done.resolve();
- return;
- }
- deltas++;
- state = foldEvent(state, msg.event);
- if (msg.event.type === "turn-sealed") {
- sealed = true;
- done.resolve();
- }
- });
-
- socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL });
- const timeout = setTimeout(() => done.resolve(), 90_000);
- await done.promise;
- clearTimeout(timeout);
- handlers.delete(conversationId);
-
- record(
- "regression: a real text turn sealed cleanly",
- sealed && error === null,
- `${deltas} deltas${error ? ` err=${error}` : ""}`,
- );
- record(
- "regression: providerRetry stayed NULL through a normal turn (no spurious banner)",
- selectProviderRetry(state) === null,
- );
-
- // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─
- console.log("\n[probe] parser+reducer seam (synthetic provider-retry)");
- let s = initialState();
- s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" });
-
- const retryJson = deltaFrame({
- type: "provider-retry",
- conversationId,
- turnId: "t1",
- attempt: 0,
- delayMs: 5000,
- message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}',
- code: "429",
- });
- const parsed1 = parseServerMessage(retryJson);
- record(
- "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)",
- parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry",
- parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null",
- );
-
- if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event);
- const retry1 = selectProviderRetry(s);
- record(
- "foldEvent SETS providerRetry from the PARSED event",
- retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429",
- retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null",
- );
- record(
- "provider-retry adds NO chunk (never persisted — never pollutes the prompt)",
- selectChunks(s).length === 0,
- `${selectChunks(s).length} chunk(s)`,
- );
-
- const retry2Json = deltaFrame({
- type: "provider-retry",
- conversationId,
- turnId: "t1",
- attempt: 1,
- delayMs: 10000,
- message: "HTTP 429: still overloaded",
- code: "429",
- });
- const parsed2 = parseServerMessage(retry2Json);
- if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event);
- const retry2 = selectProviderRetry(s);
- record(
- "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)",
- retry2 !== null &&
- retry2.attempt === 1 &&
- retry2.delayMs === 10000 &&
- retry2.message === "HTTP 429: still overloaded",
- retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null",
- );
-
- const textJson = deltaFrame({
- type: "text-delta",
- conversationId,
- turnId: "t1",
- delta: "here is the reply",
- });
- const parsedText = parseServerMessage(textJson);
- if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event);
- record(
- "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)",
- selectProviderRetry(s) === null,
- );
- record(
- "…and the text-delta content DID land as a chunk (the reply streams normally after retries)",
- selectChunks(s).some((c) => c.chunk.type === "text"),
- );
-
- socket.close();
- const passed = checks.filter((c) => c.ok).length;
- const total = checks.length;
- console.log(`\n[probe] ${passed}/${total} checks passed`);
- process.exit(passed === total ? 0 : 1);
+ console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`);
+
+ // ─── 1. REGRESSION: a real text turn through the updated foldEvent ──────────
+ // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts).
+ const handlers = new Map<string, (msg: ChatMsg) => void>();
+ const socket = createSurfaceSocket({
+ url: WS_URL,
+ onMessage: (_m: SurfaceServerMessage) => {},
+ onChat: (msg: ChatMsg) => {
+ const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ const h = id !== undefined ? handlers.get(id) : undefined;
+ h?.(msg);
+ },
+ });
+ await new Promise((r) => setTimeout(r, 500));
+
+ const conversationId = crypto.randomUUID();
+ let state = initialState();
+ let deltas = 0;
+ let sealed = false;
+ let error: string | null = null;
+ const done = Promise.withResolvers<void>();
+ handlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ error = msg.message;
+ done.resolve();
+ return;
+ }
+ deltas++;
+ state = foldEvent(state, msg.event);
+ if (msg.event.type === "turn-sealed") {
+ sealed = true;
+ done.resolve();
+ }
+ });
+
+ socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL });
+ const timeout = setTimeout(() => done.resolve(), 90_000);
+ await done.promise;
+ clearTimeout(timeout);
+ handlers.delete(conversationId);
+
+ record(
+ "regression: a real text turn sealed cleanly",
+ sealed && error === null,
+ `${deltas} deltas${error ? ` err=${error}` : ""}`,
+ );
+ record(
+ "regression: providerRetry stayed NULL through a normal turn (no spurious banner)",
+ selectProviderRetry(state) === null,
+ );
+
+ // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─
+ console.log("\n[probe] parser+reducer seam (synthetic provider-retry)");
+ let s = initialState();
+ s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" });
+
+ const retryJson = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 0,
+ delayMs: 5000,
+ message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}',
+ code: "429",
+ });
+ const parsed1 = parseServerMessage(retryJson);
+ record(
+ "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)",
+ parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry",
+ parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null",
+ );
+
+ if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event);
+ const retry1 = selectProviderRetry(s);
+ record(
+ "foldEvent SETS providerRetry from the PARSED event",
+ retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429",
+ retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null",
+ );
+ record(
+ "provider-retry adds NO chunk (never persisted — never pollutes the prompt)",
+ selectChunks(s).length === 0,
+ `${selectChunks(s).length} chunk(s)`,
+ );
+
+ const retry2Json = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 1,
+ delayMs: 10000,
+ message: "HTTP 429: still overloaded",
+ code: "429",
+ });
+ const parsed2 = parseServerMessage(retry2Json);
+ if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event);
+ const retry2 = selectProviderRetry(s);
+ record(
+ "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)",
+ retry2 !== null &&
+ retry2.attempt === 1 &&
+ retry2.delayMs === 10000 &&
+ retry2.message === "HTTP 429: still overloaded",
+ retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null",
+ );
+
+ const textJson = deltaFrame({
+ type: "text-delta",
+ conversationId,
+ turnId: "t1",
+ delta: "here is the reply",
+ });
+ const parsedText = parseServerMessage(textJson);
+ if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event);
+ record(
+ "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)",
+ selectProviderRetry(s) === null,
+ );
+ record(
+ "…and the text-delta content DID land as a chunk (the reply streams normally after retries)",
+ selectChunks(s).some((c) => c.chunk.type === "text"),
+ );
+
+ socket.close();
+ const passed = checks.filter((c) => c.ok).length;
+ const total = checks.length;
+ console.log(`\n[probe] ${passed}/${total} checks passed`);
+ process.exit(passed === total ? 0 : 1);
}
main().catch((e) => fail(String(e)));
diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts
index f44a136..6121eac 100644
--- a/scripts/live-probe.ts
+++ b/scripts/live-probe.ts
@@ -27,29 +27,29 @@
// browser has these natively; Bun does not). The product code is unchanged.
import "fake-indexeddb/auto";
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ConversationHistoryResponse,
- ConversationMetricsResponse,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
import { createIdbChunkStore } from "../src/adapters/idb/index.ts";
import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
import {
- applyHistory,
- foldEvent,
- groupRenderedChunks,
- initialState,
- selectChunks,
- selectMessages,
- type TranscriptState,
+ applyHistory,
+ foldEvent,
+ groupRenderedChunks,
+ initialState,
+ selectChunks,
+ selectMessages,
+ type TranscriptState,
} from "../src/core/chunks/index.ts";
import {
- applyDurableMetrics,
- foldMetricsEvent,
- initialMetricsState,
- type MetricsState,
- selectOrderedTurnMetrics,
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ type MetricsState,
+ selectOrderedTurnMetrics,
} from "../src/core/metrics/index.ts";
import { createConversationCache } from "../src/features/conversation-cache/index.ts";
@@ -58,51 +58,51 @@ const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203";
const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
const TEXT_PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch";
const TOOL_PROMPT =
- process.env.PROBE_TOOL_PROMPT ??
- "Make two tool calls AT THE SAME TIME in a single step (parallel tool calls). " +
- "For example, run two independent shell commands together: `echo alpha` and `echo beta`. " +
- "If you have no shell tool, invoke any two of your available read-only tools simultaneously.";
+ process.env.PROBE_TOOL_PROMPT ??
+ "Make two tool calls AT THE SAME TIME in a single step (parallel tool calls). " +
+ "For example, run two independent shell commands together: `echo alpha` and `echo beta`. " +
+ "If you have no shell tool, invoke any two of your available read-only tools simultaneously.";
const checks: { name: string; ok: boolean; detail?: string }[] = [];
const record = (name: string, ok: boolean, detail?: string) => {
- checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
- console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
+ checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
};
const note = (msg: string) => console.log(` ℹ️ ${msg}`);
function fail(msg: string): never {
- console.error(`\n[live-probe] FATAL: ${msg}`);
- process.exit(1);
+ console.error(`\n[live-probe] FATAL: ${msg}`);
+ process.exit(1);
}
async function historySync(
- id: string,
- sinceSeq: number,
- window?: { limit?: number; beforeSeq?: number },
+ id: string,
+ sinceSeq: number,
+ window?: { limit?: number; beforeSeq?: number },
): Promise<ConversationHistoryResponse> {
- let url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`;
- if (window?.limit !== undefined) url += `&limit=${window.limit}`;
- if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
- const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
- if (!res.ok) fail(`history fetch ${res.status} for ${url}`);
- return (await res.json()) as ConversationHistoryResponse;
+ let url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`;
+ if (window?.limit !== undefined) url += `&limit=${window.limit}`;
+ if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
+ const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
+ if (!res.ok) fail(`history fetch ${res.status} for ${url}`);
+ return (await res.json()) as ConversationHistoryResponse;
}
/** Raw history GET that returns the status (for the CR-5 validation checks). */
async function historyStatus(id: string, query: string): Promise<number> {
- const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?${query}`;
- const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
- await res.arrayBuffer(); // drain
- return res.status;
+ const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?${query}`;
+ const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
+ await res.arrayBuffer(); // drain
+ return res.status;
}
/** Durable metrics fetch — returns the response, or the HTTP status when not OK
* (the endpoint is being implemented backend-side; the FE tolerates a 404). */
async function metricsSync(id: string): Promise<ConversationMetricsResponse | { status: number }> {
- const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`;
- const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
- if (!res.ok) return { status: res.status };
- return (await res.json()) as ConversationMetricsResponse;
+ const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`;
+ const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
+ if (!res.ok) return { status: res.status };
+ return (await res.json()) as ConversationMetricsResponse;
}
type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
@@ -110,295 +110,295 @@ type Socket = ReturnType<typeof createSurfaceSocket>;
const handlers = new Map<string, (msg: ChatMsg) => void>();
function convOf(msg: ChatMsg): string | undefined {
- return msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ return msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
}
/** Drive one turn to turn-sealed (or error), folding events into a fresh state. */
async function runTurn(
- socket: Socket,
- conversationId: string,
- prompt: string,
+ socket: Socket,
+ conversationId: string,
+ prompt: string,
): Promise<{
- state: TranscriptState;
- metrics: MetricsState;
- deltas: number;
- sealed: boolean;
- error: string | null;
+ state: TranscriptState;
+ metrics: MetricsState;
+ deltas: number;
+ sealed: boolean;
+ error: string | null;
}> {
- let state = initialState();
- let metrics = initialMetricsState();
- let deltas = 0;
- let sealed = false;
- let error: string | null = null;
- const done = Promise.withResolvers<void>();
+ let state = initialState();
+ let metrics = initialMetricsState();
+ let deltas = 0;
+ let sealed = false;
+ let error: string | null = null;
+ const done = Promise.withResolvers<void>();
- handlers.set(conversationId, (msg) => {
- if (msg.type === "chat.error") {
- error = msg.message;
- done.resolve();
- return;
- }
- deltas++;
- state = foldEvent(state, msg.event);
- metrics = foldMetricsEvent(metrics, msg.event);
- if (msg.event.type === "turn-sealed") {
- sealed = true;
- done.resolve();
- }
- });
+ handlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ error = msg.message;
+ done.resolve();
+ return;
+ }
+ deltas++;
+ state = foldEvent(state, msg.event);
+ metrics = foldMetricsEvent(metrics, msg.event);
+ if (msg.event.type === "turn-sealed") {
+ sealed = true;
+ done.resolve();
+ }
+ });
- socket.send({ type: "chat.send", conversationId, message: prompt, model: MODEL });
- const timeout = setTimeout(() => done.resolve(), 90_000);
- await done.promise;
- clearTimeout(timeout);
- handlers.delete(conversationId);
- return { state, metrics, deltas, sealed, error };
+ socket.send({ type: "chat.send", conversationId, message: prompt, model: MODEL });
+ const timeout = setTimeout(() => done.resolve(), 90_000);
+ await done.promise;
+ clearTimeout(timeout);
+ handlers.delete(conversationId);
+ return { state, metrics, deltas, sealed, error };
}
function toolChunksOf(state: TranscriptState) {
- return selectChunks(state).filter(
- (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
- );
+ return selectChunks(state).filter(
+ (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
+ );
}
async function main() {
- console.log(`[live-probe] model=${MODEL}`);
- console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`);
+ console.log(`[live-probe] model=${MODEL}`);
+ console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`);
- const cache = createConversationCache(createIdbChunkStore());
+ const cache = createConversationCache(createIdbChunkStore());
- let gotCatalog = false;
- const socket = createSurfaceSocket({
- url: WS_URL,
- onMessage: (m: SurfaceServerMessage) => {
- if (m.type === "catalog") {
- gotCatalog = true;
- console.log(` ↳ surface catalog: ${m.catalog.length} surface(s)`);
- }
- },
- onChat: (msg: ChatMsg) => {
- const id = convOf(msg);
- const h = id !== undefined ? handlers.get(id) : undefined;
- if (h) h(msg);
- },
- });
+ let gotCatalog = false;
+ const socket = createSurfaceSocket({
+ url: WS_URL,
+ onMessage: (m: SurfaceServerMessage) => {
+ if (m.type === "catalog") {
+ gotCatalog = true;
+ console.log(` ↳ surface catalog: ${m.catalog.length} surface(s)`);
+ }
+ },
+ onChat: (msg: ChatMsg) => {
+ const id = convOf(msg);
+ const h = id !== undefined ? handlers.get(id) : undefined;
+ if (h) h(msg);
+ },
+ });
- await new Promise((r) => setTimeout(r, 500));
- record("WS connected + surface catalog received", gotCatalog);
+ await new Promise((r) => setTimeout(r, 500));
+ record("WS connected + surface catalog received", gotCatalog);
- // ─── Turn 1: text streaming + cache + replay ────────────────────────────────
- console.log(`\n[live-probe] TURN 1 (text): "${TEXT_PROMPT}"`);
- const textConv = crypto.randomUUID();
- const t1 = await runTurn(socket, textConv, TEXT_PROMPT);
- if (t1.error !== null) record("turn 1 had no chat.error", false, t1.error);
- record("turn 1 received chat.delta events", t1.deltas > 0, `${t1.deltas} deltas`);
- record("turn 1 reached turn-sealed", t1.sealed);
+ // ─── Turn 1: text streaming + cache + replay ────────────────────────────────
+ console.log(`\n[live-probe] TURN 1 (text): "${TEXT_PROMPT}"`);
+ const textConv = crypto.randomUUID();
+ const t1 = await runTurn(socket, textConv, TEXT_PROMPT);
+ if (t1.error !== null) record("turn 1 had no chat.error", false, t1.error);
+ record("turn 1 received chat.delta events", t1.deltas > 0, `${t1.deltas} deltas`);
+ record("turn 1 reached turn-sealed", t1.sealed);
- let state = t1.state;
- const sinceSeq = await cache.sinceSeq(textConv);
- const hist = await historySync(textConv, sinceSeq);
- record(
- "turn 1 history endpoint returned chunks",
- hist.chunks.length > 0,
- `${hist.chunks.length} chunks, latestSeq=${hist.latestSeq}`,
- );
- const monotonic = hist.chunks.every((c, i) => i === 0 || c.seq > (hist.chunks[i - 1]?.seq ?? -1));
- record("turn 1 history chunks are seq-monotonic", monotonic);
- const merged = await cache.commit(textConv, hist.chunks);
- state = applyHistory(state, merged);
- record("turn 1 provisional superseded (sealedTurnId cleared)", state.sealedTurnId === null);
- const cached = await cache.load(textConv);
- record("turn 1 IndexedDB cache persisted the turn", cached.length === hist.chunks.length);
- const committedText = selectMessages(state)
- .filter((m) => m.role === "assistant")
- .flatMap((m) => m.chunks)
- .filter((c) => c.type === "text")
- .map((c) => (c as { text: string }).text)
- .join("");
- record("turn 1 committed transcript has assistant text", committedText.length > 0);
+ let state = t1.state;
+ const sinceSeq = await cache.sinceSeq(textConv);
+ const hist = await historySync(textConv, sinceSeq);
+ record(
+ "turn 1 history endpoint returned chunks",
+ hist.chunks.length > 0,
+ `${hist.chunks.length} chunks, latestSeq=${hist.latestSeq}`,
+ );
+ const monotonic = hist.chunks.every((c, i) => i === 0 || c.seq > (hist.chunks[i - 1]?.seq ?? -1));
+ record("turn 1 history chunks are seq-monotonic", monotonic);
+ const merged = await cache.commit(textConv, hist.chunks);
+ state = applyHistory(state, merged);
+ record("turn 1 provisional superseded (sealedTurnId cleared)", state.sealedTurnId === null);
+ const cached = await cache.load(textConv);
+ record("turn 1 IndexedDB cache persisted the turn", cached.length === hist.chunks.length);
+ const committedText = selectMessages(state)
+ .filter((m) => m.role === "assistant")
+ .flatMap((m) => m.chunks)
+ .filter((c) => c.type === "text")
+ .map((c) => (c as { text: string }).text)
+ .join("");
+ record("turn 1 committed transcript has assistant text", committedText.length > 0);
- // ─── CR-5: history windowing (?limit= / ?beforeSeq=, [email protected]) ───────
- const logLen = hist.chunks.length;
- record(
- "CR-5 seq origin: first chunk is seq 1 (1-based gap-free contract)",
- hist.chunks[0]?.seq === 1,
- `first seq=${hist.chunks[0]?.seq}`,
- );
- const win = await historySync(textConv, 0, { limit: 2 });
- record(
- "CR-5 ?limit=2 returns the NEWEST 2, ascending, latestSeq = window tail",
- win.chunks.length === Math.min(2, logLen) &&
- win.chunks[0]?.seq === Math.max(1, logLen - 1) &&
- win.chunks[win.chunks.length - 1]?.seq === logLen &&
- win.latestSeq === logLen,
- `seqs=[${win.chunks.map((c) => c.seq).join(",")}] latestSeq=${win.latestSeq}`,
- );
- const whole = await historySync(textConv, 0, { limit: 200 });
- record(
- "CR-5 ?limit= larger than the log returns everything (short-chat flow exact)",
- whole.chunks.length === logLen,
- `${whole.chunks.length}/${logLen} chunks`,
- );
- const oldestLoaded = win.chunks[0]?.seq ?? 0;
- if (oldestLoaded > 1) {
- const back = await historySync(textConv, 0, { beforeSeq: oldestLoaded, limit: 50 });
- record(
- "CR-5 ?beforeSeq= pages the older run (seq < bound, ascending from 1)",
- back.chunks.length === oldestLoaded - 1 &&
- back.chunks[0]?.seq === 1 &&
- back.chunks.every((c) => c.seq < oldestLoaded),
- `seqs=[${back.chunks.map((c) => c.seq).join(",")}]`,
- );
- }
- record("CR-5 limit=0 rejected with 400", (await historyStatus(textConv, "limit=0")) === 400);
- record(
- "CR-5 beforeSeq=-1 rejected with 400",
- (await historyStatus(textConv, "beforeSeq=-1")) === 400,
- );
+ // ─── CR-5: history windowing (?limit= / ?beforeSeq=, [email protected]) ───────
+ const logLen = hist.chunks.length;
+ record(
+ "CR-5 seq origin: first chunk is seq 1 (1-based gap-free contract)",
+ hist.chunks[0]?.seq === 1,
+ `first seq=${hist.chunks[0]?.seq}`,
+ );
+ const win = await historySync(textConv, 0, { limit: 2 });
+ record(
+ "CR-5 ?limit=2 returns the NEWEST 2, ascending, latestSeq = window tail",
+ win.chunks.length === Math.min(2, logLen) &&
+ win.chunks[0]?.seq === Math.max(1, logLen - 1) &&
+ win.chunks[win.chunks.length - 1]?.seq === logLen &&
+ win.latestSeq === logLen,
+ `seqs=[${win.chunks.map((c) => c.seq).join(",")}] latestSeq=${win.latestSeq}`,
+ );
+ const whole = await historySync(textConv, 0, { limit: 200 });
+ record(
+ "CR-5 ?limit= larger than the log returns everything (short-chat flow exact)",
+ whole.chunks.length === logLen,
+ `${whole.chunks.length}/${logLen} chunks`,
+ );
+ const oldestLoaded = win.chunks[0]?.seq ?? 0;
+ if (oldestLoaded > 1) {
+ const back = await historySync(textConv, 0, { beforeSeq: oldestLoaded, limit: 50 });
+ record(
+ "CR-5 ?beforeSeq= pages the older run (seq < bound, ascending from 1)",
+ back.chunks.length === oldestLoaded - 1 &&
+ back.chunks[0]?.seq === 1 &&
+ back.chunks.every((c) => c.seq < oldestLoaded),
+ `seqs=[${back.chunks.map((c) => c.seq).join(",")}]`,
+ );
+ }
+ record("CR-5 limit=0 rejected with 400", (await historyStatus(textConv, "limit=0")) === 400);
+ record(
+ "CR-5 beforeSeq=-1 rejected with 400",
+ (await historyStatus(textConv, "beforeSeq=-1")) === 400,
+ );
- // ─── Metrics: LIVE token + timing ([email protected] usage/step-complete/done) ──────
- // (TurnMetricsEntry is `{ turnId, steps, total }` — the turn aggregate lives on
- // `total`, present once the live `done` folded.)
- const liveTurns = selectOrderedTurnMetrics(t1.metrics);
- const m1 = liveTurns[0];
- const m1Total = m1?.total ?? null;
- record(
- "turn 1 LIVE metrics: a turn with output tokens",
- m1Total !== null && m1Total.usage.outputTokens > 0,
- m1Total
- ? `in=${m1Total.usage.inputTokens} out=${m1Total.usage.outputTokens} steps=${m1?.steps.length}`
- : "no finalized turn total",
- );
- if (m1 !== undefined) {
- const anyGen = m1.steps.some((s) => s.genTotalMs !== undefined);
- const anyTtft = m1.steps.some((s) => s.ttftMs !== undefined);
- note(
- `live timing: durationMs=${m1Total?.durationMs ?? "—"}, ` +
- `genTotalMs present=${anyGen}, ttftMs present=${anyTtft}`,
- );
- record(
- "turn 1 LIVE metrics carries timing (durationMs or step genTotalMs)",
- m1Total?.durationMs !== undefined || anyGen,
- "requires the backend runtime to have a clock",
- );
- }
+ // ─── Metrics: LIVE token + timing ([email protected] usage/step-complete/done) ──────
+ // (TurnMetricsEntry is `{ turnId, steps, total }` — the turn aggregate lives on
+ // `total`, present once the live `done` folded.)
+ const liveTurns = selectOrderedTurnMetrics(t1.metrics);
+ const m1 = liveTurns[0];
+ const m1Total = m1?.total ?? null;
+ record(
+ "turn 1 LIVE metrics: a turn with output tokens",
+ m1Total !== null && m1Total.usage.outputTokens > 0,
+ m1Total
+ ? `in=${m1Total.usage.inputTokens} out=${m1Total.usage.outputTokens} steps=${m1?.steps.length}`
+ : "no finalized turn total",
+ );
+ if (m1 !== undefined) {
+ const anyGen = m1.steps.some((s) => s.genTotalMs !== undefined);
+ const anyTtft = m1.steps.some((s) => s.ttftMs !== undefined);
+ note(
+ `live timing: durationMs=${m1Total?.durationMs ?? "—"}, ` +
+ `genTotalMs present=${anyGen}, ttftMs present=${anyTtft}`,
+ );
+ record(
+ "turn 1 LIVE metrics carries timing (durationMs or step genTotalMs)",
+ m1Total?.durationMs !== undefined || anyGen,
+ "requires the backend runtime to have a clock",
+ );
+ }
- // ─── Metrics: DURABLE endpoint (GET /conversations/:id/metrics) ──────────────
- const dm = await metricsSync(textConv);
- if ("status" in dm) {
- note(
- `durable /metrics not available yet (HTTP ${dm.status}) — FE degrades to live-only, as designed`,
- );
- record(
- "durable /metrics is implemented OR gracefully absent (404)",
- dm.status === 404 || dm.status === 405,
- `HTTP ${dm.status}`,
- );
- } else {
- record(
- "durable /metrics returned TurnMetrics[]",
- Array.isArray(dm.turns),
- `${dm.turns.length} turn(s)`,
- );
- const durableMerged = selectOrderedTurnMetrics(
- applyDurableMetrics(initialMetricsState(), dm.turns),
- );
- const d1 = durableMerged[0];
- const d1Total = d1?.total ?? null;
- record(
- "durable /metrics turn has token usage",
- d1Total !== null && d1Total.usage.outputTokens > 0,
- d1Total ? `out=${d1Total.usage.outputTokens} steps=${d1?.steps.length}` : "no turn total",
- );
- }
+ // ─── Metrics: DURABLE endpoint (GET /conversations/:id/metrics) ──────────────
+ const dm = await metricsSync(textConv);
+ if ("status" in dm) {
+ note(
+ `durable /metrics not available yet (HTTP ${dm.status}) — FE degrades to live-only, as designed`,
+ );
+ record(
+ "durable /metrics is implemented OR gracefully absent (404)",
+ dm.status === 404 || dm.status === 405,
+ `HTTP ${dm.status}`,
+ );
+ } else {
+ record(
+ "durable /metrics returned TurnMetrics[]",
+ Array.isArray(dm.turns),
+ `${dm.turns.length} turn(s)`,
+ );
+ const durableMerged = selectOrderedTurnMetrics(
+ applyDurableMetrics(initialMetricsState(), dm.turns),
+ );
+ const d1 = durableMerged[0];
+ const d1Total = d1?.total ?? null;
+ record(
+ "durable /metrics turn has token usage",
+ d1Total !== null && d1Total.usage.outputTokens > 0,
+ d1Total ? `out=${d1Total.usage.outputTokens} steps=${d1?.steps.length}` : "no turn total",
+ );
+ }
- // ─── Turn 2: tool-call batching ([email protected] stepId) ─────────────────────────
- console.log(`\n[live-probe] TURN 2 (tools): "${TOOL_PROMPT}"`);
- const toolConv = crypto.randomUUID();
- const t2 = await runTurn(socket, toolConv, TOOL_PROMPT);
- if (t2.error !== null) record("turn 2 had no chat.error", false, t2.error);
- record("turn 2 reached turn-sealed", t2.sealed);
+ // ─── Turn 2: tool-call batching ([email protected] stepId) ─────────────────────────
+ console.log(`\n[live-probe] TURN 2 (tools): "${TOOL_PROMPT}"`);
+ const toolConv = crypto.randomUUID();
+ const t2 = await runTurn(socket, toolConv, TOOL_PROMPT);
+ if (t2.error !== null) record("turn 2 had no chat.error", false, t2.error);
+ record("turn 2 reached turn-sealed", t2.sealed);
- const liveTool = toolChunksOf(t2.state);
- const liveCalls = liveTool.filter((c) => c.chunk.type === "tool-call");
+ const liveTool = toolChunksOf(t2.state);
+ const liveCalls = liveTool.filter((c) => c.chunk.type === "tool-call");
- if (liveCalls.length === 0) {
- note(
- "INCONCLUSIVE: the model issued no tool calls this run — cannot verify stepId grouping live. " +
- "Re-run with a stronger PROBE_TOOL_PROMPT or one tailored to the backend's tool set.",
- );
- record("turn 2 tool-call batching (live)", true, "skipped — no tool calls issued");
- } else {
- // Every live tool chunk must carry stepId (foldEvent copies it from the event).
- const allLiveHaveStep = liveTool.every(
- (c) =>
- (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") &&
- typeof c.chunk.stepId === "string" &&
- c.chunk.stepId.length > 0,
- );
- record(
- "turn 2 every LIVE tool event carries stepId",
- allLiveHaveStep,
- `${liveCalls.length} call(s), ${liveTool.length - liveCalls.length} result(s)`,
- );
+ if (liveCalls.length === 0) {
+ note(
+ "INCONCLUSIVE: the model issued no tool calls this run — cannot verify stepId grouping live. " +
+ "Re-run with a stronger PROBE_TOOL_PROMPT or one tailored to the backend's tool set.",
+ );
+ record("turn 2 tool-call batching (live)", true, "skipped — no tool calls issued");
+ } else {
+ // Every live tool chunk must carry stepId (foldEvent copies it from the event).
+ const allLiveHaveStep = liveTool.every(
+ (c) =>
+ (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") &&
+ typeof c.chunk.stepId === "string" &&
+ c.chunk.stepId.length > 0,
+ );
+ record(
+ "turn 2 every LIVE tool event carries stepId",
+ allLiveHaveStep,
+ `${liveCalls.length} call(s), ${liveTool.length - liveCalls.length} result(s)`,
+ );
- const liveGroups = groupRenderedChunks(selectChunks(t2.state));
- const liveBatches = liveGroups.filter((g) => g.kind === "tool-batch");
- const distinctSteps = new Set(
- liveCalls.map((c) => (c.chunk.type === "tool-call" ? c.chunk.stepId : undefined)),
- );
- note(
- `live grouping: ${liveCalls.length} call(s) across ${distinctSteps.size} step(s) → ` +
- `${liveBatches.length} batch group(s)`,
- );
- if (liveBatches.length > 0) {
- record(
- "turn 2 grouping produced a parallel batch (2+ calls in one step)",
- true,
- `${liveBatches.length} batch(es)`,
- );
- } else {
- note(
- "the model used tools but did NOT parallelize (each call its own step) — stepId is verified, " +
- "but no multi-call batch occurred to render as a list this run.",
- );
- }
+ const liveGroups = groupRenderedChunks(selectChunks(t2.state));
+ const liveBatches = liveGroups.filter((g) => g.kind === "tool-batch");
+ const distinctSteps = new Set(
+ liveCalls.map((c) => (c.chunk.type === "tool-call" ? c.chunk.stepId : undefined)),
+ );
+ note(
+ `live grouping: ${liveCalls.length} call(s) across ${distinctSteps.size} step(s) → ` +
+ `${liveBatches.length} batch group(s)`,
+ );
+ if (liveBatches.length > 0) {
+ record(
+ "turn 2 grouping produced a parallel batch (2+ calls in one step)",
+ true,
+ `${liveBatches.length} batch(es)`,
+ );
+ } else {
+ note(
+ "the model used tools but did NOT parallelize (each call its own step) — stepId is verified, " +
+ "but no multi-call batch occurred to render as a list this run.",
+ );
+ }
- // Replay path: persisted tool chunks must also carry chunk.stepId.
- const histTool = await historySync(toolConv, 0);
- const replayTool = histTool.chunks.filter(
- (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
- );
- const allReplayHaveStep = replayTool.every(
- (c) =>
- (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") &&
- typeof c.chunk.stepId === "string" &&
- c.chunk.stepId.length > 0,
- );
- record(
- "turn 2 every REPLAYED tool chunk carries chunk.stepId",
- replayTool.length > 0 && allReplayHaveStep,
- `${replayTool.length} tool chunk(s) in history`,
- );
+ // Replay path: persisted tool chunks must also carry chunk.stepId.
+ const histTool = await historySync(toolConv, 0);
+ const replayTool = histTool.chunks.filter(
+ (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
+ );
+ const allReplayHaveStep = replayTool.every(
+ (c) =>
+ (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") &&
+ typeof c.chunk.stepId === "string" &&
+ c.chunk.stepId.length > 0,
+ );
+ record(
+ "turn 2 every REPLAYED tool chunk carries chunk.stepId",
+ replayTool.length > 0 && allReplayHaveStep,
+ `${replayTool.length} tool chunk(s) in history`,
+ );
- // Grouping on the authoritative replayed history matches the live shape.
- const replayState = applyHistory(initialState(), await cache.commit(toolConv, histTool.chunks));
- const replayBatches = groupRenderedChunks(selectChunks(replayState)).filter(
- (g) => g.kind === "tool-batch",
- );
- record(
- "turn 2 replay grouping matches live (batch count)",
- replayBatches.length === liveBatches.length,
- `live=${liveBatches.length} replay=${replayBatches.length}`,
- );
- }
+ // Grouping on the authoritative replayed history matches the live shape.
+ const replayState = applyHistory(initialState(), await cache.commit(toolConv, histTool.chunks));
+ const replayBatches = groupRenderedChunks(selectChunks(replayState)).filter(
+ (g) => g.kind === "tool-batch",
+ );
+ record(
+ "turn 2 replay grouping matches live (batch count)",
+ replayBatches.length === liveBatches.length,
+ `live=${liveBatches.length} replay=${replayBatches.length}`,
+ );
+ }
- socket.close();
+ socket.close();
- const passed = checks.filter((c) => c.ok).length;
- const total = checks.length;
- console.log(`\n[live-probe] ${passed}/${total} checks passed`);
- process.exit(passed === total ? 0 : 1);
+ const passed = checks.filter((c) => c.ok).length;
+ const total = checks.length;
+ console.log(`\n[live-probe] ${passed}/${total} checks passed`);
+ process.exit(passed === total ? 0 : 1);
}
main().catch((e) => fail(String(e)));
diff --git a/scripts/probe-cache-warming.ts b/scripts/probe-cache-warming.ts
index 470e43b..1bf1f9b 100644
--- a/scripts/probe-cache-warming.ts
+++ b/scripts/probe-cache-warming.ts
@@ -14,9 +14,9 @@
* bun scripts/probe-cache-warming.ts
*/
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- CloseConversationResponse,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ CloseConversationResponse,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
@@ -28,17 +28,17 @@ const SURFACE_ID = "cache-warming";
const checks: { name: string; ok: boolean }[] = [];
const record = (name: string, ok: boolean, detail?: string) => {
- checks.push({ name, ok });
- console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
+ checks.push({ name, ok });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
};
const log = (msg: string) => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function summarize(spec: SurfaceSpec | null): string {
- const c = parseControls(spec);
- const next =
- c.nextWarmAt === null ? "null" : `${Math.round((c.nextWarmAt - Date.now()) / 1000)}s`;
- return `enabled=${c.enabled} interval=${c.intervalSeconds}s lastPct=${c.lastPct} next=${next} lastWarmAt=${c.lastWarmAt}`;
+ const c = parseControls(spec);
+ const next =
+ c.nextWarmAt === null ? "null" : `${Math.round((c.nextWarmAt - Date.now()) / 1000)}s`;
+ return `enabled=${c.enabled} interval=${c.intervalSeconds}s lastPct=${c.lastPct} next=${next} lastWarmAt=${c.lastWarmAt}`;
}
let catalog: { id: string; scope?: string }[] = [];
@@ -49,229 +49,229 @@ let specWaiter: (() => void) | null = null;
const chatHandlers = new Map<string, (msg: ChatDeltaMessage | ChatErrorMessage) => void>();
const socket = createSurfaceSocket({
- url: WS_URL,
- onMessage: (m: SurfaceServerMessage) => {
- if (m.type === "catalog") {
- catalog = [...m.catalog];
- log(`catalog: ${m.catalog.map((e) => `${e.id}(scope=${e.scope ?? "—"})`).join(", ")}`);
- } else if (m.type === "surface") {
- latestSpec = m.spec;
- latestSpecConv = m.conversationId;
- log(`surface(initial) conv=${m.conversationId ?? "—"}: ${summarize(m.spec)}`);
- specWaiter?.();
- } else if (m.type === "update") {
- if (m.update.surfaceId !== SURFACE_ID) return;
- latestSpec = m.update.spec;
- latestSpecConv = m.update.conversationId;
- log(`update conv=${m.update.conversationId ?? "—"}: ${summarize(m.update.spec)}`);
- specWaiter?.();
- } else if (m.type === "error") {
- log(`surface ERROR: ${m.surfaceId ?? "—"}: ${m.message}`);
- }
- },
- onChat: (msg) => {
- const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
- if (id !== undefined) chatHandlers.get(id)?.(msg);
- },
+ url: WS_URL,
+ onMessage: (m: SurfaceServerMessage) => {
+ if (m.type === "catalog") {
+ catalog = [...m.catalog];
+ log(`catalog: ${m.catalog.map((e) => `${e.id}(scope=${e.scope ?? "—"})`).join(", ")}`);
+ } else if (m.type === "surface") {
+ latestSpec = m.spec;
+ latestSpecConv = m.conversationId;
+ log(`surface(initial) conv=${m.conversationId ?? "—"}: ${summarize(m.spec)}`);
+ specWaiter?.();
+ } else if (m.type === "update") {
+ if (m.update.surfaceId !== SURFACE_ID) return;
+ latestSpec = m.update.spec;
+ latestSpecConv = m.update.conversationId;
+ log(`update conv=${m.update.conversationId ?? "—"}: ${summarize(m.update.spec)}`);
+ specWaiter?.();
+ } else if (m.type === "error") {
+ log(`surface ERROR: ${m.surfaceId ?? "—"}: ${m.message}`);
+ }
+ },
+ onChat: (msg) => {
+ const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ if (id !== undefined) chatHandlers.get(id)?.(msg);
+ },
});
/** Wait for the next surface/update message (or time out). */
function nextSpec(timeoutMs: number): Promise<boolean> {
- return new Promise((resolve) => {
- const t = setTimeout(() => {
- specWaiter = null;
- resolve(false);
- }, timeoutMs);
- specWaiter = () => {
- clearTimeout(t);
- specWaiter = null;
- resolve(true);
- };
- });
+ return new Promise((resolve) => {
+ const t = setTimeout(() => {
+ specWaiter = null;
+ resolve(false);
+ }, timeoutMs);
+ specWaiter = () => {
+ clearTimeout(t);
+ specWaiter = null;
+ resolve(true);
+ };
+ });
}
async function runTinyTurn(conversationId: string, prompt: string): Promise<boolean> {
- const done = Promise.withResolvers<boolean>();
- chatHandlers.set(conversationId, (msg) => {
- if (msg.type === "chat.error") {
- log(`chat.error: ${msg.message}`);
- done.resolve(false);
- } else if (msg.event.type === "turn-sealed") {
- done.resolve(true);
- }
- });
- socket.send({ type: "chat.send", conversationId, message: prompt });
- const t = setTimeout(() => done.resolve(false), 90_000);
- const ok = await done.promise;
- clearTimeout(t);
- chatHandlers.delete(conversationId);
- return ok;
+ const done = Promise.withResolvers<boolean>();
+ chatHandlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ log(`chat.error: ${msg.message}`);
+ done.resolve(false);
+ } else if (msg.event.type === "turn-sealed") {
+ done.resolve(true);
+ }
+ });
+ socket.send({ type: "chat.send", conversationId, message: prompt });
+ const t = setTimeout(() => done.resolve(false), 90_000);
+ const ok = await done.promise;
+ clearTimeout(t);
+ chatHandlers.delete(conversationId);
+ return ok;
}
function invoke(actionId: string, conversationId: string, payload?: unknown): void {
- socket.send(
- payload === undefined
- ? { type: "invoke", surfaceId: SURFACE_ID, actionId, conversationId }
- : { type: "invoke", surfaceId: SURFACE_ID, actionId, payload, conversationId },
- );
+ socket.send(
+ payload === undefined
+ ? { type: "invoke", surfaceId: SURFACE_ID, actionId, conversationId }
+ : { type: "invoke", surfaceId: SURFACE_ID, actionId, payload, conversationId },
+ );
}
async function main() {
- await sleep(600);
- record(
- "catalog includes cache-warming with scope=conversation",
- catalog.some((e) => e.id === SURFACE_ID && e.scope === "conversation"),
- );
+ await sleep(600);
+ record(
+ "catalog includes cache-warming with scope=conversation",
+ catalog.some((e) => e.id === SURFACE_ID && e.scope === "conversation"),
+ );
- // ── A: the DRAFT/new-tab path — subscribe with NO conversationId ───────────
- log("PHASE A: subscribe with NO conversationId (draft / new tab)");
- socket.send({ type: "subscribe", surfaceId: SURFACE_ID });
- await nextSpec(3000);
- record(
- "draft subscribe → degenerate spec (no toggle parsed)",
- !parseControls(latestSpec).enabled,
- );
- socket.send({ type: "unsubscribe", surfaceId: SURFACE_ID });
- await sleep(300);
+ // ── A: the DRAFT/new-tab path — subscribe with NO conversationId ───────────
+ log("PHASE A: subscribe with NO conversationId (draft / new tab)");
+ socket.send({ type: "subscribe", surfaceId: SURFACE_ID });
+ await nextSpec(3000);
+ record(
+ "draft subscribe → degenerate spec (no toggle parsed)",
+ !parseControls(latestSpec).enabled,
+ );
+ socket.send({ type: "unsubscribe", surfaceId: SURFACE_ID });
+ await sleep(300);
- // ── B: a FRESH conversation defaults OFF (CR-4a) + echo (CR-4d) ────────────
- const conv = crypto.randomUUID();
- log(`PHASE B: creating conversation ${conv}`);
- if (!(await runTinyTurn(conv, "Reply with exactly: ok"))) {
- log("FATAL: could not create a conversation");
- process.exit(1);
- }
- socket.send({ type: "subscribe", surfaceId: SURFACE_ID, conversationId: conv });
- await nextSpec(3000);
- const fresh = parseControls(latestSpec);
- record("CR-4d: initial surface message echoes conversationId", latestSpecConv === conv);
- record("CR-4a: fresh conversation defaults to warming OFF", fresh.enabled === false);
- record("CR-4a: nothing scheduled while off (nextWarmAt null)", fresh.nextWarmAt === null);
+ // ── B: a FRESH conversation defaults OFF (CR-4a) + echo (CR-4d) ────────────
+ const conv = crypto.randomUUID();
+ log(`PHASE B: creating conversation ${conv}`);
+ if (!(await runTinyTurn(conv, "Reply with exactly: ok"))) {
+ log("FATAL: could not create a conversation");
+ process.exit(1);
+ }
+ socket.send({ type: "subscribe", surfaceId: SURFACE_ID, conversationId: conv });
+ await nextSpec(3000);
+ const fresh = parseControls(latestSpec);
+ record("CR-4d: initial surface message echoes conversationId", latestSpecConv === conv);
+ record("CR-4a: fresh conversation defaults to warming OFF", fresh.enabled === false);
+ record("CR-4a: nothing scheduled while off (nextWarmAt null)", fresh.nextWarmAt === null);
- // ── C: opt in + 10s interval → repeated warms, FUTURE nextWarmAt (CR-4b) ───
- log("PHASE C: toggling warming ON");
- const toggleId = fresh.toggleActionId;
- if (toggleId === null) {
- record("toggle action present", false);
- process.exit(1);
- }
- invoke(toggleId, conv);
- await nextSpec(3000);
- let c = parseControls(latestSpec);
- record("toggle-on update arrived (enabled)", c.enabled === true);
- record(
- "CR-4b: enable schedules a FUTURE nextWarmAt",
- c.nextWarmAt !== null && c.nextWarmAt > Date.now(),
- );
+ // ── C: opt in + 10s interval → repeated warms, FUTURE nextWarmAt (CR-4b) ───
+ log("PHASE C: toggling warming ON");
+ const toggleId = fresh.toggleActionId;
+ if (toggleId === null) {
+ record("toggle action present", false);
+ process.exit(1);
+ }
+ invoke(toggleId, conv);
+ await nextSpec(3000);
+ let c = parseControls(latestSpec);
+ record("toggle-on update arrived (enabled)", c.enabled === true);
+ record(
+ "CR-4b: enable schedules a FUTURE nextWarmAt",
+ c.nextWarmAt !== null && c.nextWarmAt > Date.now(),
+ );
- const setIntervalId = c.setIntervalActionId;
- if (setIntervalId !== null) {
- log("PHASE C: set-interval = 10s");
- invoke(setIntervalId, conv, 10);
- await nextSpec(3000);
- c = parseControls(latestSpec);
- record(
- "set-interval update: interval=10 + FUTURE nextWarmAt",
- c.intervalSeconds === 10 && c.nextWarmAt !== null && c.nextWarmAt > Date.now(),
- );
- }
+ const setIntervalId = c.setIntervalActionId;
+ if (setIntervalId !== null) {
+ log("PHASE C: set-interval = 10s");
+ invoke(setIntervalId, conv, 10);
+ await nextSpec(3000);
+ c = parseControls(latestSpec);
+ record(
+ "set-interval update: interval=10 + FUTURE nextWarmAt",
+ c.intervalSeconds === 10 && c.nextWarmAt !== null && c.nextWarmAt > Date.now(),
+ );
+ }
- log("PHASE C: waiting up to 45s for 2 automatic warms…");
- const deadline = Date.now() + 45_000;
- let lastSeen = c.lastWarmAt;
- let warms = 0;
- let allFuture = true;
- while (Date.now() < deadline && warms < 2) {
- await nextSpec(Math.max(1, deadline - Date.now()));
- const now = parseControls(latestSpec);
- if (now.lastWarmAt !== null && now.lastWarmAt !== lastSeen) {
- lastSeen = now.lastWarmAt;
- warms++;
- const future = now.nextWarmAt !== null && now.nextWarmAt > Date.now() - 1000;
- if (!future) allFuture = false;
- log(
- ` automatic warm #${warms}: pct=${now.lastPct} retention=${now.retentionPct} ` +
- `nextWarmAt ${future ? "FUTURE" : "STALE/PAST"}`,
- );
- }
- }
- record("automatic warms repeat (2 observed @10s)", warms >= 2, `${warms} warm(s)`);
- record("CR-4b: every post-warm update carries a FUTURE nextWarmAt", warms >= 2 && allFuture);
+ log("PHASE C: waiting up to 45s for 2 automatic warms…");
+ const deadline = Date.now() + 45_000;
+ let lastSeen = c.lastWarmAt;
+ let warms = 0;
+ let allFuture = true;
+ while (Date.now() < deadline && warms < 2) {
+ await nextSpec(Math.max(1, deadline - Date.now()));
+ const now = parseControls(latestSpec);
+ if (now.lastWarmAt !== null && now.lastWarmAt !== lastSeen) {
+ lastSeen = now.lastWarmAt;
+ warms++;
+ const future = now.nextWarmAt !== null && now.nextWarmAt > Date.now() - 1000;
+ if (!future) allFuture = false;
+ log(
+ ` automatic warm #${warms}: pct=${now.lastPct} retention=${now.retentionPct} ` +
+ `nextWarmAt ${future ? "FUTURE" : "STALE/PAST"}`,
+ );
+ }
+ }
+ record("automatic warms repeat (2 observed @10s)", warms >= 2, `${warms} warm(s)`);
+ record("CR-4b: every post-warm update carries a FUTURE nextWarmAt", warms >= 2 && allFuture);
- // ── D: close mid-turn → abort + warming disabled (CR-4c) ───────────────────
- log("PHASE D: starting a long turn, then closing the conversation mid-turn…");
- const seenDone = Promise.withResolvers<string>(); // resolves with done.reason
- const seenSealed = Promise.withResolvers<void>();
- let turnStarted = false;
- const started = Promise.withResolvers<void>();
- chatHandlers.set(conv, (msg) => {
- if (msg.type === "chat.error") {
- log(`chat.error: ${msg.message}`);
- return;
- }
- const ev = msg.event;
- if (ev.type === "turn-start") {
- turnStarted = true;
- started.resolve();
- } else if (ev.type === "done") {
- seenDone.resolve(ev.reason);
- } else if (ev.type === "turn-sealed") {
- seenSealed.resolve();
- }
- });
- socket.send({
- type: "chat.send",
- conversationId: conv,
- message:
- "Write a detailed 1000-word essay about the history of computing. Take your time and be thorough.",
- });
- const startTimeout = setTimeout(() => started.resolve(), 15_000);
- await started.promise;
- clearTimeout(startTimeout);
- record("turn started (watcher saw turn-start)", turnStarted);
- await sleep(1000); // let it generate a moment
+ // ── D: close mid-turn → abort + warming disabled (CR-4c) ───────────────────
+ log("PHASE D: starting a long turn, then closing the conversation mid-turn…");
+ const seenDone = Promise.withResolvers<string>(); // resolves with done.reason
+ const seenSealed = Promise.withResolvers<void>();
+ let turnStarted = false;
+ const started = Promise.withResolvers<void>();
+ chatHandlers.set(conv, (msg) => {
+ if (msg.type === "chat.error") {
+ log(`chat.error: ${msg.message}`);
+ return;
+ }
+ const ev = msg.event;
+ if (ev.type === "turn-start") {
+ turnStarted = true;
+ started.resolve();
+ } else if (ev.type === "done") {
+ seenDone.resolve(ev.reason);
+ } else if (ev.type === "turn-sealed") {
+ seenSealed.resolve();
+ }
+ });
+ socket.send({
+ type: "chat.send",
+ conversationId: conv,
+ message:
+ "Write a detailed 1000-word essay about the history of computing. Take your time and be thorough.",
+ });
+ const startTimeout = setTimeout(() => started.resolve(), 15_000);
+ await started.promise;
+ clearTimeout(startTimeout);
+ record("turn started (watcher saw turn-start)", turnStarted);
+ await sleep(1000); // let it generate a moment
- const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, {
- method: "POST",
- headers: { Origin: "http://localhost:24204" },
- });
- record("POST /conversations/:id/close → 200", res.ok, `HTTP ${res.status}`);
- const body = (await res.json()) as CloseConversationResponse;
- record("close aborted the in-flight turn (abortedTurn)", body.abortedTurn === true);
+ const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, {
+ method: "POST",
+ headers: { Origin: "http://localhost:24204" },
+ });
+ record("POST /conversations/:id/close → 200", res.ok, `HTTP ${res.status}`);
+ const body = (await res.json()) as CloseConversationResponse;
+ record("close aborted the in-flight turn (abortedTurn)", body.abortedTurn === true);
- const doneReason = await Promise.race([seenDone.promise, sleep(15_000).then(() => "(timeout)")]);
- record('watcher received done with reason "aborted"', doneReason === "aborted", doneReason);
- const sealed = await Promise.race([
- seenSealed.promise.then(() => true),
- sleep(15_000).then(() => false),
- ]);
- record("turn sealed normally after abort", sealed);
- chatHandlers.delete(conv);
+ const doneReason = await Promise.race([seenDone.promise, sleep(15_000).then(() => "(timeout)")]);
+ record('watcher received done with reason "aborted"', doneReason === "aborted", doneReason);
+ const sealed = await Promise.race([
+ seenSealed.promise.then(() => true),
+ sleep(15_000).then(() => false),
+ ]);
+ record("turn sealed normally after abort", sealed);
+ chatHandlers.delete(conv);
- // The close also pushed a surface update: warming disabled + unscheduled.
- await sleep(1500);
- const closed = parseControls(latestSpec);
- record(
- "CR-4c: close disabled warming + cleared the schedule",
- closed.enabled === false && closed.nextWarmAt === null,
- summarize(latestSpec),
- );
+ // The close also pushed a surface update: warming disabled + unscheduled.
+ await sleep(1500);
+ const closed = parseControls(latestSpec);
+ record(
+ "CR-4c: close disabled warming + cleared the schedule",
+ closed.enabled === false && closed.nextWarmAt === null,
+ summarize(latestSpec),
+ );
- // Idempotency: closing again (now idle) succeeds with abortedTurn false.
- const res2 = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, {
- method: "POST",
- headers: { Origin: "http://localhost:24204" },
- });
- const body2 = (await res2.json()) as CloseConversationResponse;
- record("close is idempotent (200 + abortedTurn:false)", res2.ok && body2.abortedTurn === false);
+ // Idempotency: closing again (now idle) succeeds with abortedTurn false.
+ const res2 = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, {
+ method: "POST",
+ headers: { Origin: "http://localhost:24204" },
+ });
+ const body2 = (await res2.json()) as CloseConversationResponse;
+ record("close is idempotent (200 + abortedTurn:false)", res2.ok && body2.abortedTurn === false);
- socket.close();
- const passed = checks.filter((x) => x.ok).length;
- console.log(`\n[probe-cache-warming] ${passed}/${checks.length} checks passed`);
- process.exit(passed === checks.length ? 0 : 1);
+ socket.close();
+ const passed = checks.filter((x) => x.ok).length;
+ console.log(`\n[probe-cache-warming] ${passed}/${checks.length} checks passed`);
+ process.exit(passed === checks.length ? 0 : 1);
}
main().catch((e) => {
- console.error(`[probe] FATAL: ${e}`);
- process.exit(1);
+ console.error(`[probe] FATAL: ${e}`);
+ process.exit(1);
});
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 @@
<script lang="ts">
- import { App, createAppStore } from "./app";
- import { createHistoryAdapter } from "./adapters/history";
- import {
- createWorkspaceHttp,
- createWorkspaceStore,
- pageTitle,
- parsePath,
- WorkspacesHome,
- type Route,
- } from "./features/workspaces";
- import { resolveHttpUrl } from "./app/resolve-http-url";
+ import { App, createAppStore } from "./app";
+ import { createHistoryAdapter } from "./adapters/history";
+ import {
+ createWorkspaceHttp,
+ createWorkspaceStore,
+ pageTitle,
+ parsePath,
+ WorkspacesHome,
+ type Route,
+ } from "./features/workspaces";
+ import { resolveHttpUrl } from "./app/resolve-http-url";
- // Parse the route BEFORE creating the store so the boot draft + active
- // workspace are correct from the first render (no flash of the "default"
- // workspace when deep-linking to /<id>).
- const history = createHistoryAdapter();
- const initialRoute = parsePath(history.path);
- const store = createAppStore(
- initialRoute.kind === "workspace" ? { workspaceId: initialRoute.id } : {},
- );
+ // Parse the route BEFORE creating the store so the boot draft + active
+ // workspace are correct from the first render (no flash of the "default"
+ // workspace when deep-linking to /<id>).
+ const history = createHistoryAdapter();
+ const initialRoute = parsePath(history.path);
+ const store = createAppStore(
+ initialRoute.kind === "workspace" ? { workspaceId: initialRoute.id } : {},
+ );
- // The workspace HTTP edge shares the same httpBase resolution as the store.
- const httpBase = resolveHttpUrl(
- {
- VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL,
- VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT,
- },
- typeof location !== "undefined" ? location : undefined,
- );
- const workspaceStore = createWorkspaceStore(
- createWorkspaceHttp(httpBase, globalThis.fetch.bind(globalThis)),
- );
+ // The workspace HTTP edge shares the same httpBase resolution as the store.
+ const httpBase = resolveHttpUrl(
+ {
+ VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL,
+ VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT,
+ },
+ typeof location !== "undefined" ? location : undefined,
+ );
+ const workspaceStore = createWorkspaceStore(
+ createWorkspaceHttp(httpBase, globalThis.fetch.bind(globalThis)),
+ );
- let route = $state<Route>(initialRoute);
+ let route = $state<Route>(initialRoute);
- // React to back/forward + programmatic navigation.
- $effect(() => {
- return history.subscribe((path) => {
- route = parsePath(path);
- });
- });
+ // React to back/forward + programmatic navigation.
+ $effect(() => {
+ return history.subscribe((path) => {
+ route = parsePath(path);
+ });
+ });
- // On entering a workspace: scope the store to it (idempotent — skip if already
- // scoped) + ensure the workspace exists (create-on-miss, so it appears in the
- // home list immediately). The backend also auto-creates on `chat.send`.
- $effect(() => {
- if (route.kind !== "workspace") return;
- const id = route.id;
- if (store.activeWorkspaceId !== id) {
- store.setActiveWorkspace(id);
- }
- void workspaceStore.ensure(id);
- });
+ // On entering a workspace: scope the store to it (idempotent — skip if already
+ // scoped) + ensure the workspace exists (create-on-miss, so it appears in the
+ // home list immediately). The backend also auto-creates on `chat.send`.
+ $effect(() => {
+ if (route.kind !== "workspace") return;
+ const id = route.id;
+ if (store.activeWorkspaceId !== id) {
+ store.setActiveWorkspace(id);
+ }
+ void workspaceStore.ensure(id);
+ });
- // Keep the browser tab title in sync with the route: "Dispatch" on the home
- // page, "Dispatch: {title}" on a workspace page (falling back to the URL slug
- // until the list loads it). Reads `route` + the workspace list, so it re-runs
- // on navigation and again once `ensure` refreshes the list.
- $effect(() => {
- if (typeof document === "undefined") return;
- document.title = pageTitle(route, workspaceStore.list);
- });
+ // Keep the browser tab title in sync with the route: "Dispatch" on the home
+ // page, "Dispatch: {title}" on a workspace page (falling back to the URL slug
+ // until the list loads it). Reads `route` + the workspace list, so it re-runs
+ // on navigation and again once `ensure` refreshes the list.
+ $effect(() => {
+ if (typeof document === "undefined") return;
+ document.title = pageTitle(route, workspaceStore.list);
+ });
- function navigate(path: string): void {
- history.navigate(path);
- }
+ function navigate(path: string): void {
+ history.navigate(path);
+ }
</script>
{#if route.kind === "home"}
- <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} />
+ <WorkspacesHome store={workspaceStore} onNavigate={navigate} computers={store.computers} />
{:else}
- <App {store} />
+ <App {store} />
{/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<T>(req: IDBRequest<T>): Promise<T> {
- return new Promise<T>((resolve, reject) => {
- req.onsuccess = () => resolve(req.result);
- req.onerror = () => reject(req.error);
- });
+ return new Promise<T>((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
}
function txComplete(tx: IDBTransaction): Promise<void> {
- return new Promise<void>((resolve, reject) => {
- tx.oncomplete = () => resolve();
- tx.onerror = () => reject(tx.error);
- tx.onabort = () => reject(tx.error);
- });
+ return new Promise<void>((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ });
}
function openDb(idb: IDBFactory, dbName: string): Promise<IDBDatabase> {
- return new Promise<IDBDatabase>((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<IDBDatabase>((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<IDBDatabase> | null = null;
-
- function getDb(): Promise<IDBDatabase> {
- if (dbPromise === null) {
- dbPromise = openDb(idb, dbName);
- }
- return dbPromise;
- }
-
- return {
- async load(conversationId: string): Promise<readonly StoredChunk[]> {
- 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<ChunkRecord[]>(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<void> {
- 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<void> {
- 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<readonly ConversationCacheIndexEntry[]> {
- 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<ChunkRecord[]>(chunkStore.getAll());
- const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll());
- await txComplete(tx);
-
- const metaMap = new Map<string, number>();
- for (const m of allMeta) {
- metaMap.set(m.conversationId, m.lastAccess);
- }
-
- const grouped = new Map<string, { chunkCount: number; maxSeq: number }>();
- 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<IDBDatabase> | null = null;
+
+ function getDb(): Promise<IDBDatabase> {
+ if (dbPromise === null) {
+ dbPromise = openDb(idb, dbName);
+ }
+ return dbPromise;
+ }
+
+ return {
+ async load(conversationId: string): Promise<readonly StoredChunk[]> {
+ 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<ChunkRecord[]>(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<void> {
+ 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<void> {
+ 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<readonly ConversationCacheIndexEntry[]> {
+ 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<ChunkRecord[]>(chunkStore.getAll());
+ const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll());
+ await txComplete(tx);
+
+ const metaMap = new Map<string, number>();
+ for (const m of allMeta) {
+ metaMap.set(m.conversationId, m.lastAccess);
+ }
+
+ const grouped = new Map<string, { chunkCount: number; maxSeq: number }>();
+ 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<string, string>();
- 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<string, string>();
+ 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<string>("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<object>("corrupt", { storage });
-
- expect(store.load()).toBeNull();
- });
-
- it("clear removes the value", () => {
- const storage = createMemoryStorage();
- const store = createLocalStore<string>("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<number[]>("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<string>("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<number[]>("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<string>("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<string>("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<object>("corrupt", { storage });
+
+ expect(store.load()).toBeNull();
+ });
+
+ it("clear removes the value", () => {
+ const storage = createMemoryStorage();
+ const store = createLocalStore<string>("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<number[]>("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<string>("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<number[]>("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<string>("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<T> {
- 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<T>(): LocalStore<T> {
- return {
- load() {
- return null;
- },
- save() {},
- clear() {},
- };
+ return {
+ load() {
+ return null;
+ },
+ save() {},
+ clear() {},
+ };
}
export function createLocalStore<T>(key: string, opts?: CreateLocalStoreOptions): LocalStore<T> {
- 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<T>();
- }
+ if (storage === undefined || storage === null) {
+ return createNoopStore<T>();
+ }
- 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<typeof fakeSocket>[] = [];
- 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<typeof fakeSocket>[] = [];
- 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<typeof fakeSocket>[] = [];
+ 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<typeof fakeSocket>[] = [];
+ 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<typeof setTimeout> | null = null;
- let isOpen = false;
- const queue: string[] = [];
+ let socket: WebSocketLike | null = null;
+ let disposed = false;
+ let reconnectAttempt = 0;
+ let reconnectTimer: ReturnType<typeof setTimeout> | 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<string, unknown> {
- 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<string, unknown> {
* 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 <html data-theme="monokai">). 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 @@
<script lang="ts">
- import type { ReasoningEffort } from "@dispatch/transport-contract";
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import { tick } from "svelte";
- import Table from "../components/Table.svelte";
- import {
- CacheWarmingView,
- manifest as cacheWarmingManifest,
- type WarmFeedback,
- } from "../features/cache-warming";
- import {
- ChatView,
- CompactionView,
- Composer,
- manifest as chatManifest,
- ModelSelector,
- ReasoningEffortSelector,
- type CompactNowResult,
- type ReasoningEffortSaveResult,
- type SaveCompactPercentResult,
- } from "../features/chat";
- import { manifest as conversationCacheManifest } from "../features/conversation-cache";
- import { manifest as markdownManifest } from "../features/markdown";
- import {
- McpStatusView,
- manifest as mcpManifest,
- type McpStatusResult,
- } from "../features/mcp";
- import {
- ChatLimitField,
- manifest as settingsManifest,
- type ChatLimitSaveResult,
- } from "../features/settings";
- import {
- createSmartScrollController,
- manifest as smartScrollManifest,
- ScrollToBottom,
- } from "../features/smart-scroll";
- import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
- import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
- import { parseTodoPayload } from "../features/surface-host/logic/todo";
- import TodoList from "../features/surface-host/ui/TodoList.svelte";
- import { manifest as tabsManifest, TabBar } from "../features/tabs";
- import { manifest as viewsManifest, ViewSidebar } from "../features/views";
- import {
- CwdField,
- type CwdSaveResult,
- LspStatusView,
- type LspStatusResult,
- manifest as cwdLspManifest,
- } from "../features/cwd-lsp";
- import {
- ComputerField,
- manifest as computerManifest,
- type ComputerSaveResult,
- type ComputerStatusResult,
- type LoadComputerStatus,
- type SaveComputer,
- type TestComputer,
- type TestComputerResult,
- } from "../features/computer";
- import {
- SystemPromptBuilder,
- type LoadSystemPrompt as LoadSystemPromptAlias,
- type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
- type SaveSystemPrompt as SaveSystemPromptAlias,
- manifest as systemPromptManifest,
- } from "../features/system-prompt";
- import type { AppStore } from "./store.svelte";
- import ErrorModal from "./ErrorModal.svelte";
- import { createLocalStore } from "../adapters/local-storage";
- import { untrack } from "svelte";
-
- let { store }: { store: AppStore } = $props();
-
- // The backend's conversation-scoped cache-warming surface. Referenced by id at
- // the composition root (sanctioned discovery-by-id) to give it a dedicated view
- // and keep it out of the generic Extensions surface list — SurfaceView itself
- // stays fully generic (it never switches on a surface id).
- const CACHE_WARMING_ID = "cache-warming";
- // The message-queue extension's per-conversation surface (steering). Pulled
- // out of the generic Extensions list and rendered as a compact panel above the
- // composer — pending steering messages are tied to the chat, not the sidebar.
- const MESSAGE_QUEUE_ID = "message-queue";
- // The `todo` extension's per-conversation task list surface (model-maintained).
- const TODO_ID = "todo";
-
- // The view kinds offered in the sidebar's dropdown. Generic data — the
- // `viewContent` snippet below maps each kind id to its renderer.
- const viewKinds = [
- { id: "model", label: "Model" },
- { id: "lsp", label: "Language Servers" },
- { id: "mcp", label: "MCP Servers" },
- { id: "extensions", label: "Extensions" },
- { id: "cache-warming", label: "Cache Warming" },
- { id: "tasks", label: "Tasks" },
- { id: "compaction", label: "Compaction" },
- { id: "system-prompt", label: "System Prompt" },
- { id: "settings", label: "Settings" },
- ] as const;
-
- // Default sidebar layout: just the Model view.
- const DEFAULT_VIEWS: readonly string[] = ["model"];
- const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
- storage: untrack(() => store.storage),
- });
- const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS;
-
- function handleSidebarChange(kinds: readonly (string | null)[]): void {
- sidebarStore.save(kinds.filter((k): k is string => k !== null));
- }
-
- // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
- // feature's public `manifest` export so it can't drift from what's actually
- // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
- // backend-owned list.) FE features are internal units of this single repo, so
- // there is no per-module version — they all share dispatch-web's version.
- const MODULE_COLUMNS = ["Module", "Description"] as const;
- const loadedModules: readonly (readonly [string, string])[] = [
- chatManifest,
- tabsManifest,
- surfaceHostManifest,
- viewsManifest,
- conversationCacheManifest,
- markdownManifest,
- cacheWarmingManifest,
- cwdLspManifest,
- mcpManifest,
- computerManifest,
- smartScrollManifest,
- settingsManifest,
- systemPromptManifest,
- ].map((m) => [m.name, m.description] as const);
-
- // Smart-scroll: keep the transcript pinned to the bottom while it streams,
- // unless the reader has scrolled up (then show a "scroll to bottom" button).
- // One controller owns the chat scroll region; effects below feed it the edges.
- const smartScroll = createSmartScrollController();
- let transcriptEl = $state<HTMLElement | undefined>();
- let transcriptContentEl = $state<HTMLElement | undefined>();
-
- // Chat-limit unload gate: old chunks may be unloaded only while the reader is
- // stuck to the bottom. While stuck, a trim removes content far ABOVE the
- // viewport and the controller re-pins to the bottom — no visible jump; while
- // reading history, trimming is deferred instead of yanking the page (the old
- // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
- $effect(() => {
- store.attachUnloadGate(() => smartScroll.isAtBottom());
- });
-
- // "Show earlier messages": page older history back in, preserving the reader's
- // viewport position — prepended content grows scrollHeight, so shift scrollTop
- // by the growth (the manual analogue of CSS scroll anchoring, which not every
- // engine applies here).
- async function handleShowEarlier(): Promise<void> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- await store.activeChat.showEarlier();
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- }
-
- // Attach/detach the controller to the live scroll element + content (disposed on
- // unmount). The content element is observed (ResizeObserver) so the view follows
- // height changes that aren't a transcript append.
- $effect(() => {
- if (!transcriptEl) return;
- return smartScroll.attach(transcriptEl, transcriptContentEl);
- });
-
- // New transcript content streamed in (or messages loaded) → follow the bottom
- // while stuck. Reads `chunks.length` so the effect re-runs on every append.
- $effect(() => {
- void store.activeChat.chunks.length;
- smartScroll.contentChanged();
- });
-
- // The message-queue surface spec + whether it currently has pending messages
- // (steering). Rendered as a compact panel above the composer only when non-empty.
- const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
- const hasQueuedMessages = $derived.by(() => {
- const spec = messageQueueSpec;
- if (spec === null) return false;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
- if (field === undefined || field.kind !== "custom") return false;
- const data = parseMessageQueuePayload(field.payload);
- return data !== null && data.messages.length > 0;
- });
-
- // The todo surface spec + its parsed task list (model-maintained, read-only).
- const todoSpec = $derived(store.surface(TODO_ID));
- const todoData = $derived.by(() => {
- const spec = todoSpec;
- if (spec === null) return null;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
- if (field === undefined || field.kind !== "custom") return null;
- return parseTodoPayload(field.payload);
- });
-
- // Conversation/tab switch → snap to the bottom of the new transcript.
- $effect(() => {
- void store.activeConversationId;
- smartScroll.reset();
- });
-
- // Right sidebar: persisted open/closed state. Defaults to open on wide
- // screens (first visit), then remembers the user's toggle thereafter.
- const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
- const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", {
- storage: untrack(() => store.storage),
- });
- const storedSidebarOpen = sidebarOpenStore.load();
- let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true));
- let systemPromptModalOpen = $state(false);
-
- $effect(() => {
- sidebarOpenStore.save(sidebarOpen);
- });
-
- function handleInvoke(msg: InvokeMessage) {
- store.invoke(msg.surfaceId, msg.actionId, msg.payload);
- }
-
- function handleSend(text: string) {
- store.send(text);
- }
-
- function handleQueue(text: string) {
- store.queueMessage(text);
- }
-
- function handleStop() {
- store.stopGeneration();
- }
-
- function handleSelectModel(model: string) {
- store.selectModel(model);
- }
-
- // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
- async function warmNow(): Promise<WarmFeedback | null> {
- const result = await store.warmNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- cachePct: result.response.cachePct,
- expectedCacheRate: result.response.expectedCacheRate,
- }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's reasoning-effort result to the chat feature's port.
- async function saveReasoningEffort(
- level: ReasoningEffort,
- ): Promise<ReasoningEffortSaveResult | null> {
- const result = await store.setReasoningEffort(level);
- if (result === null) return null;
- return result.ok
- ? { ok: true, reasoningEffort: result.reasoningEffort }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's compact result to the compaction view's port.
- async function compactNow(): Promise<CompactNowResult | null> {
- const result = await store.compactNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- messagesSummarized: result.response.messagesSummarized,
- messagesKept: result.response.messagesKept,
- }
- : { ok: false, error: result.error };
- }
-
- async function saveCompactPercent(
- percent: number,
- ): Promise<SaveCompactPercentResult | null> {
- const result = await store.setCompactPercent(percent);
- if (result === null) return null;
- return result.ok
- ? { ok: true, percent: result.percent }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's chat-limit result to the settings feature's port. On a
- // raise the active chat refills (prepends older history); preserve the
- // reader's viewport over the prepend (the manual analogue of CSS scroll
- // anchoring), exactly like `handleShowEarlier`.
- async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- const result = await store.setChatLimit(value);
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- return result.ok
- ? { ok: true, chatLimit: result.chatLimit }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports.
- async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
- const result = await store.setCwd(cwd);
- if (result === null) return null;
- return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
- }
-
- async function loadLspStatus(): Promise<LspStatusResult | null> {
- const result = await store.lspStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's computer results to the computer feature's ports.
- async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
- const result = await store.setComputer(computerId);
- if (result === null) return null;
- return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error };
- }
-
- const loadComputerStatus: LoadComputerStatus = async (
- alias: string,
- ): Promise<ComputerStatusResult | null> => {
- const result = await store.computerStatus(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
- };
-
- const testComputer: TestComputer = async (
- alias: string,
- ): Promise<TestComputerResult | null> => {
- const result = await store.testComputer(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
- };
-
- async function loadMcpStatus(): Promise<McpStatusResult | null> {
- const result = await store.mcpStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's system prompt results to the system-prompt feature's ports.
- const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt();
-
- const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () =>
- store.loadSystemPromptVariables();
-
- const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template);
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import { tick } from "svelte";
+ import Table from "../components/Table.svelte";
+ import {
+ CacheWarmingView,
+ manifest as cacheWarmingManifest,
+ type WarmFeedback,
+ } from "../features/cache-warming";
+ import {
+ ChatView,
+ CompactionView,
+ Composer,
+ manifest as chatManifest,
+ ModelSelector,
+ ReasoningEffortSelector,
+ type CompactNowResult,
+ type ReasoningEffortSaveResult,
+ type SaveCompactPercentResult,
+ } from "../features/chat";
+ import { manifest as conversationCacheManifest } from "../features/conversation-cache";
+ import { manifest as markdownManifest } from "../features/markdown";
+ import { McpStatusView, manifest as mcpManifest, type McpStatusResult } from "../features/mcp";
+ import {
+ ChatLimitField,
+ manifest as settingsManifest,
+ type ChatLimitSaveResult,
+ } from "../features/settings";
+ import {
+ createSmartScrollController,
+ manifest as smartScrollManifest,
+ ScrollToBottom,
+ } from "../features/smart-scroll";
+ import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
+ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
+ import { parseTodoPayload } from "../features/surface-host/logic/todo";
+ import TodoList from "../features/surface-host/ui/TodoList.svelte";
+ import { manifest as tabsManifest, TabBar } from "../features/tabs";
+ import { manifest as viewsManifest, ViewSidebar } from "../features/views";
+ import {
+ CwdField,
+ type CwdSaveResult,
+ LspStatusView,
+ type LspStatusResult,
+ manifest as cwdLspManifest,
+ } from "../features/cwd-lsp";
+ import {
+ ComputerField,
+ manifest as computerManifest,
+ type ComputerSaveResult,
+ type ComputerStatusResult,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ type TestComputerResult,
+ } from "../features/computer";
+ import {
+ SystemPromptBuilder,
+ type LoadSystemPrompt as LoadSystemPromptAlias,
+ type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
+ type SaveSystemPrompt as SaveSystemPromptAlias,
+ manifest as systemPromptManifest,
+ } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ import ErrorModal from "./ErrorModal.svelte";
+ import { createLocalStore } from "../adapters/local-storage";
+ import { untrack } from "svelte";
+
+ let { store }: { store: AppStore } = $props();
+
+ // The backend's conversation-scoped cache-warming surface. Referenced by id at
+ // the composition root (sanctioned discovery-by-id) to give it a dedicated view
+ // and keep it out of the generic Extensions surface list — SurfaceView itself
+ // stays fully generic (it never switches on a surface id).
+ const CACHE_WARMING_ID = "cache-warming";
+ // The message-queue extension's per-conversation surface (steering). Pulled
+ // out of the generic Extensions list and rendered as a compact panel above the
+ // composer — pending steering messages are tied to the chat, not the sidebar.
+ const MESSAGE_QUEUE_ID = "message-queue";
+ // The `todo` extension's per-conversation task list surface (model-maintained).
+ const TODO_ID = "todo";
+
+ // The view kinds offered in the sidebar's dropdown. Generic data — the
+ // `viewContent` snippet below maps each kind id to its renderer.
+ const viewKinds = [
+ { id: "model", label: "Model" },
+ { id: "lsp", label: "Language Servers" },
+ { id: "mcp", label: "MCP Servers" },
+ { id: "extensions", label: "Extensions" },
+ { id: "cache-warming", label: "Cache Warming" },
+ { id: "tasks", label: "Tasks" },
+ { id: "compaction", label: "Compaction" },
+ { id: "system-prompt", label: "System Prompt" },
+ { id: "settings", label: "Settings" },
+ ] as const;
+
+ // Default sidebar layout: just the Model view.
+ const DEFAULT_VIEWS: readonly string[] = ["model"];
+ const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
+ storage: untrack(() => store.storage),
+ });
+ const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS;
+
+ function handleSidebarChange(kinds: readonly (string | null)[]): void {
+ sidebarStore.save(kinds.filter((k): k is string => k !== null));
+ }
+
+ // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
+ // feature's public `manifest` export so it can't drift from what's actually
+ // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
+ // backend-owned list.) FE features are internal units of this single repo, so
+ // there is no per-module version — they all share dispatch-web's version.
+ const MODULE_COLUMNS = ["Module", "Description"] as const;
+ const loadedModules: readonly (readonly [string, string])[] = [
+ chatManifest,
+ tabsManifest,
+ surfaceHostManifest,
+ viewsManifest,
+ conversationCacheManifest,
+ markdownManifest,
+ cacheWarmingManifest,
+ cwdLspManifest,
+ mcpManifest,
+ computerManifest,
+ smartScrollManifest,
+ settingsManifest,
+ systemPromptManifest,
+ ].map((m) => [m.name, m.description] as const);
+
+ // Smart-scroll: keep the transcript pinned to the bottom while it streams,
+ // unless the reader has scrolled up (then show a "scroll to bottom" button).
+ // One controller owns the chat scroll region; effects below feed it the edges.
+ const smartScroll = createSmartScrollController();
+ let transcriptEl = $state<HTMLElement | undefined>();
+ let transcriptContentEl = $state<HTMLElement | undefined>();
+
+ // Chat-limit unload gate: old chunks may be unloaded only while the reader is
+ // stuck to the bottom. While stuck, a trim removes content far ABOVE the
+ // viewport and the controller re-pins to the bottom — no visible jump; while
+ // reading history, trimming is deferred instead of yanking the page (the old
+ // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
+ $effect(() => {
+ store.attachUnloadGate(() => smartScroll.isAtBottom());
+ });
+
+ // "Show earlier messages": page older history back in, preserving the reader's
+ // viewport position — prepended content grows scrollHeight, so shift scrollTop
+ // by the growth (the manual analogue of CSS scroll anchoring, which not every
+ // engine applies here).
+ async function handleShowEarlier(): Promise<void> {
+ const el = transcriptEl;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ await store.activeChat.showEarlier();
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ }
+
+ // Attach/detach the controller to the live scroll element + content (disposed on
+ // unmount). The content element is observed (ResizeObserver) so the view follows
+ // height changes that aren't a transcript append.
+ $effect(() => {
+ if (!transcriptEl) return;
+ return smartScroll.attach(transcriptEl, transcriptContentEl);
+ });
+
+ // New transcript content streamed in (or messages loaded) → follow the bottom
+ // while stuck. Reads `chunks.length` so the effect re-runs on every append.
+ $effect(() => {
+ void store.activeChat.chunks.length;
+ smartScroll.contentChanged();
+ });
+
+ // The message-queue surface spec + whether it currently has pending messages
+ // (steering). Rendered as a compact panel above the composer only when non-empty.
+ const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
+ const hasQueuedMessages = $derived.by(() => {
+ const spec = messageQueueSpec;
+ if (spec === null) return false;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
+ if (field === undefined || field.kind !== "custom") return false;
+ const data = parseMessageQueuePayload(field.payload);
+ return data !== null && data.messages.length > 0;
+ });
+
+ // The todo surface spec + its parsed task list (model-maintained, read-only).
+ const todoSpec = $derived(store.surface(TODO_ID));
+ const todoData = $derived.by(() => {
+ const spec = todoSpec;
+ if (spec === null) return null;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
+ if (field === undefined || field.kind !== "custom") return null;
+ return parseTodoPayload(field.payload);
+ });
+
+ // Conversation/tab switch → snap to the bottom of the new transcript.
+ $effect(() => {
+ void store.activeConversationId;
+ smartScroll.reset();
+ });
+
+ // Right sidebar: persisted open/closed state. Defaults to open on wide
+ // screens (first visit), then remembers the user's toggle thereafter.
+ const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
+ const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", {
+ storage: untrack(() => store.storage),
+ });
+ const storedSidebarOpen = sidebarOpenStore.load();
+ let sidebarOpen = $state(
+ storedSidebarOpen ??
+ (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true),
+ );
+ let systemPromptModalOpen = $state(false);
+
+ $effect(() => {
+ sidebarOpenStore.save(sidebarOpen);
+ });
+
+ function handleInvoke(msg: InvokeMessage) {
+ store.invoke(msg.surfaceId, msg.actionId, msg.payload);
+ }
+
+ function handleSend(text: string) {
+ store.send(text);
+ }
+
+ function handleQueue(text: string) {
+ store.queueMessage(text);
+ }
+
+ function handleStop() {
+ store.stopGeneration();
+ }
+
+ function handleSelectModel(model: string) {
+ store.selectModel(model);
+ }
+
+ // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
+ async function warmNow(): Promise<WarmFeedback | null> {
+ const result = await store.warmNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ cachePct: result.response.cachePct,
+ expectedCacheRate: result.response.expectedCacheRate,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's reasoning-effort result to the chat feature's port.
+ async function saveReasoningEffort(
+ level: ReasoningEffort,
+ ): Promise<ReasoningEffortSaveResult | null> {
+ const result = await store.setReasoningEffort(level);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, reasoningEffort: result.reasoningEffort }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's compact result to the compaction view's port.
+ async function compactNow(): Promise<CompactNowResult | null> {
+ const result = await store.compactNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ messagesSummarized: result.response.messagesSummarized,
+ messagesKept: result.response.messagesKept,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCompactPercent(percent: number): Promise<SaveCompactPercentResult | null> {
+ const result = await store.setCompactPercent(percent);
+ if (result === null) return null;
+ return result.ok ? { ok: true, percent: result.percent } : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's chat-limit result to the settings feature's port. On a
+ // raise the active chat refills (prepends older history); preserve the
+ // reader's viewport over the prepend (the manual analogue of CSS scroll
+ // anchoring), exactly like `handleShowEarlier`.
+ async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
+ const el = transcriptEl;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await store.setChatLimit(value);
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result.ok
+ ? { ok: true, chatLimit: result.chatLimit }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports.
+ async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
+ const result = await store.setCwd(cwd);
+ if (result === null) return null;
+ return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
+ }
+
+ async function loadLspStatus(): Promise<LspStatusResult | null> {
+ const result = await store.lspStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's computer results to the computer feature's ports.
+ async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
+ const result = await store.setComputer(computerId);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, computerId: result.computerId }
+ : { ok: false, error: result.error };
+ }
+
+ const loadComputerStatus: LoadComputerStatus = async (
+ alias: string,
+ ): Promise<ComputerStatusResult | null> => {
+ const result = await store.computerStatus(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
+ };
+
+ const testComputer: TestComputer = async (alias: string): Promise<TestComputerResult | null> => {
+ const result = await store.testComputer(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
+ };
+
+ async function loadMcpStatus(): Promise<McpStatusResult | null> {
+ const result = await store.mcpStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's system prompt results to the system-prompt feature's ports.
+ const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt();
+
+ const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () =>
+ store.loadSystemPromptVariables();
+
+ const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) =>
+ store.setSystemPrompt(template);
</script>
<main class="relative flex h-screen overflow-hidden">
- <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
- (below), so opening it shrinks this ENTIRE column — tab row included, which
- slides the hamburger left. -->
- <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
- <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
- a permanently seated hamburger pinned to the far right. -->
- <div class="flex min-w-0 items-center">
- <TabBar
- tabs={store.tabs}
- activeConversationId={store.activeConversationId}
- statusFor={(id) => store.conversationStatus(id)}
- onSelect={(id) => store.selectTab(id)}
- onClose={(id) => store.closeTab(id)}
- onNewDraft={() => store.newDraft()}
- onRename={(id, title) => store.renameTab(id, title)}
- />
- <span
- class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
- title="Build version (git short hash)"
- >
- {__APP_VERSION__}
- </span>
- <button
- class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
- aria-label="Toggle sidebar"
- aria-expanded={sidebarOpen}
- onclick={() => (sidebarOpen = !sidebarOpen)}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
- />
- </svg>
- </button>
- </div>
-
- {#if store.lastError}
- <div role="alert" class="alert alert-error mx-4 mt-2">
- <strong>Error:</strong>
- {store.lastError.message}
- </div>
- {/if}
-
- {#if store.activeChat.error}
- <div role="alert" class="alert alert-warning mx-4 mt-2">
- <strong>Chat error:</strong>
- {store.activeChat.error}
- </div>
- {/if}
-
- <div class="relative min-h-0 min-w-0 flex-1">
- <div bind:this={transcriptEl} class="h-full overflow-y-auto">
- <div bind:this={transcriptContentEl}>
- {#key store.activeConversationId}
- <ChatView
- chunks={store.activeChat.chunks}
- turnMetrics={store.activeChat.turnMetrics}
- hasEarlier={store.activeChat.hasEarlier}
- onShowEarlier={handleShowEarlier}
- thinkingKeyBase={store.activeChat.thinkingKeyBase}
- providerRetry={store.activeChat.providerRetry}
- />
- {/key}
- </div>
- </div>
- {#if store.activeChat.chunks.length === 0}
- <div
- class="pointer-events-none absolute inset-0 flex items-center justify-center"
- aria-hidden="true"
- >
- <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
- </div>
- {/if}
- <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
- </div>
-
- {#if hasQueuedMessages && messageQueueSpec !== null}
- <!-- Pending steering messages (the message-queue surface). Rendered via
- the generic SurfaceView (dispatches on rendererId, never surface id);
- only shown when the queue is non-empty — an idle queue is hidden. -->
- <div class="px-4 pt-2">
- <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} />
- </div>
- {/if}
-
- <Composer
- onSend={handleSend}
- onQueue={handleQueue}
- onStop={handleStop}
- contextSize={store.activeChat.currentContextSize}
- contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
- status={store.activeChat.error
- ? "error"
- : store.activeChat.generating
- ? "running"
- : "idle"}
- />
- </div>
-
- <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
- opening it shrinks the whole left column (push). Below `lg` it overlays
- (`max-lg:absolute`, full height) with a backdrop. -->
- <aside
- class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
- class:w-80={sidebarOpen}
- class:w-0={!sidebarOpen}
- >
- <div
- class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
- </div>
- </aside>
-
- <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
- {#if sidebarOpen}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="fixed inset-0 z-20 bg-black/30 lg:hidden"
- role="button"
- tabindex="0"
- aria-label="Close sidebar"
- onclick={() => (sidebarOpen = false)}
- onkeydown={(e) => {
- if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false;
- }}
- ></div>
- {/if}
+ <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
+ (below), so opening it shrinks this ENTIRE column — tab row included, which
+ slides the hamburger left. -->
+ <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
+ <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
+ a permanently seated hamburger pinned to the far right. -->
+ <div class="flex min-w-0 items-center">
+ <TabBar
+ tabs={store.tabs}
+ activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
+ onSelect={(id) => store.selectTab(id)}
+ onClose={(id) => store.closeTab(id)}
+ onNewDraft={() => store.newDraft()}
+ onRename={(id, title) => store.renameTab(id, title)}
+ />
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
+ title="Build version (git short hash)"
+ >
+ {__APP_VERSION__}
+ </span>
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
+ aria-label="Toggle sidebar"
+ aria-expanded={sidebarOpen}
+ onclick={() => (sidebarOpen = !sidebarOpen)}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {#if store.lastError}
+ <div role="alert" class="alert alert-error mx-4 mt-2">
+ <strong>Error:</strong>
+ {store.lastError.message}
+ </div>
+ {/if}
+
+ {#if store.activeChat.error}
+ <div role="alert" class="alert alert-warning mx-4 mt-2">
+ <strong>Chat error:</strong>
+ {store.activeChat.error}
+ </div>
+ {/if}
+
+ <div class="relative min-h-0 min-w-0 flex-1">
+ <div bind:this={transcriptEl} class="h-full overflow-y-auto">
+ <div bind:this={transcriptContentEl}>
+ {#key store.activeConversationId}
+ <ChatView
+ chunks={store.activeChat.chunks}
+ turnMetrics={store.activeChat.turnMetrics}
+ hasEarlier={store.activeChat.hasEarlier}
+ onShowEarlier={handleShowEarlier}
+ thinkingKeyBase={store.activeChat.thinkingKeyBase}
+ providerRetry={store.activeChat.providerRetry}
+ />
+ {/key}
+ </div>
+ </div>
+ {#if store.activeChat.chunks.length === 0}
+ <div
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
+ aria-hidden="true"
+ >
+ <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
+ </div>
+ {/if}
+ <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
+ </div>
+
+ {#if hasQueuedMessages && messageQueueSpec !== null}
+ <!-- Pending steering messages (the message-queue surface). Rendered via
+ the generic SurfaceView (dispatches on rendererId, never surface id);
+ only shown when the queue is non-empty — an idle queue is hidden. -->
+ <div class="px-4 pt-2">
+ <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} />
+ </div>
+ {/if}
+
+ <Composer
+ onSend={handleSend}
+ onQueue={handleQueue}
+ onStop={handleStop}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ status={store.activeChat.error ? "error" : store.activeChat.generating ? "running" : "idle"}
+ />
+ </div>
+
+ <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
+ opening it shrinks the whole left column (push). Below `lg` it overlays
+ (`max-lg:absolute`, full height) with a backdrop. -->
+ <aside
+ class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
+ class:w-80={sidebarOpen}
+ class:w-0={!sidebarOpen}
+ >
+ <div
+ class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
+ style="transform: translateX({sidebarOpen ? '0' : '100%'})"
+ >
+ <ViewSidebar
+ kinds={viewKinds}
+ initial={sidebarPanels}
+ onChange={handleSidebarChange}
+ content={viewContent}
+ />
+ </div>
+ </aside>
+
+ <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
+ {#if sidebarOpen}
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="fixed inset-0 z-20 bg-black/30 lg:hidden"
+ role="button"
+ tabindex="0"
+ aria-label="Close sidebar"
+ onclick={() => (sidebarOpen = false)}
+ onkeydown={(e) => {
+ if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false;
+ }}
+ ></div>
+ {/if}
</main>
{#if store.fatalError}
- <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
+ <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
{/if}
{#if systemPromptModalOpen}
- <SystemPromptBuilder
- loadPrompt={loadSystemPromptPrompt}
- savePrompt={saveSystemPromptPrompt}
- loadVariables={loadSystemPromptVariablesPrompt}
- onClose={() => (systemPromptModalOpen = false)}
- />
+ <SystemPromptBuilder
+ loadPrompt={loadSystemPromptPrompt}
+ savePrompt={saveSystemPromptPrompt}
+ loadVariables={loadSystemPromptVariablesPrompt}
+ onClose={() => (systemPromptModalOpen = false)}
+ />
{/if}
{#snippet viewContent(kind: string)}
- {#if kind === "model"}
- <div class="flex flex-col gap-3">
- <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} />
- <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
- re-mount per conversation — incl. switching between drafts — and can't
- bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
- {#key store.currentConversationId}
- <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} />
- <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
- <ComputerField
- computerId={store.computerId}
- canEdit={true}
- computers={store.computers}
- save={saveComputer}
- loadStatus={loadComputerStatus}
- test={testComputer}
- />
- {/key}
- </div>
- {:else if kind === "lsp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
- {/key}
- {:else if kind === "mcp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
- {/key}
- {:else if kind === "extensions"}
- <section>
- <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
- <Table columns={MODULE_COLUMNS} rows={loadedModules} />
- </section>
- <section class="mt-4 flex flex-col gap-3">
- <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
- <SurfaceView {spec} onInvoke={handleInvoke} />
- {/each}
- </section>
- {:else if kind === "cache-warming"}
- <!-- Re-mount per conversation (like ChatView) so the view's local warming
- history / manual-warm feedback can't bleed across tabs. -->
- {#key store.activeConversationId}
- <CacheWarmingView
- spec={store.surface(CACHE_WARMING_ID)}
- canWarm={store.activeConversationId !== null}
- onInvoke={handleInvoke}
- {warmNow}
- />
- {/key}
- {:else if kind === "tasks"}
- <!-- Re-mount per conversation so the task list is isolated per conversation. -->
- {#key store.activeConversationId}
- {#if todoData !== null && todoData.todos.length > 0}
- <TodoList payload={todoData} />
- {:else}
- <p class="text-xs opacity-60">No tasks yet.</p>
- {/if}
- {/key}
- {:else if kind === "compaction"}
- <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. -->
- {#key store.currentConversationId}
- <CompactionView
- percent={store.compactPercent}
- canCompact={store.activeConversationId !== null}
- {compactNow}
- savePercent={saveCompactPercent}
- />
- {/key}
- {:else if kind === "system-prompt"}
- <!-- Global system prompt template. Opens a full-page modal editor (half
- template / half variable palette). Not conversation-scoped (no {#key}). -->
- <div class="flex flex-col gap-2">
- <p class="text-xs opacity-60">
- Edit the global system prompt template with variable placeholders. Opens a full-page editor.
- </p>
- <button
- type="button"
- class="btn btn-primary btn-sm"
- onclick={() => (systemPromptModalOpen = true)}
- >
- Open builder
- </button>
- </div>
- {:else if kind === "settings"}
- <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
- global), so the field stays mounted across tab switches. -->
- <div class="flex flex-col gap-3">
- <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
- </div>
- {/if}
+ {#if kind === "model"}
+ <div class="flex flex-col gap-3">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ />
+ <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
+ re-mount per conversation — incl. switching between drafts — and can't
+ bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
+ {#key store.currentConversationId}
+ <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} />
+ <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
+ <ComputerField
+ computerId={store.computerId}
+ canEdit={true}
+ computers={store.computers}
+ save={saveComputer}
+ loadStatus={loadComputerStatus}
+ test={testComputer}
+ />
+ {/key}
+ </div>
+ {:else if kind === "lsp"}
+ <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
+ {#key store.currentConversationId}
+ <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
+ {/key}
+ {:else if kind === "mcp"}
+ <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
+ {#key store.currentConversationId}
+ <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
+ {/key}
+ {:else if kind === "extensions"}
+ <section>
+ <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
+ <Table columns={MODULE_COLUMNS} rows={loadedModules} />
+ </section>
+ <section class="mt-4 flex flex-col gap-3">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
+ <SurfaceView {spec} onInvoke={handleInvoke} />
+ {/each}
+ </section>
+ {:else if kind === "cache-warming"}
+ <!-- Re-mount per conversation (like ChatView) so the view's local warming
+ history / manual-warm feedback can't bleed across tabs. -->
+ {#key store.activeConversationId}
+ <CacheWarmingView
+ spec={store.surface(CACHE_WARMING_ID)}
+ canWarm={store.activeConversationId !== null}
+ onInvoke={handleInvoke}
+ {warmNow}
+ />
+ {/key}
+ {:else if kind === "tasks"}
+ <!-- Re-mount per conversation so the task list is isolated per conversation. -->
+ {#key store.activeConversationId}
+ {#if todoData !== null && todoData.todos.length > 0}
+ <TodoList payload={todoData} />
+ {:else}
+ <p class="text-xs opacity-60">No tasks yet.</p>
+ {/if}
+ {/key}
+ {:else if kind === "compaction"}
+ <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. -->
+ {#key store.currentConversationId}
+ <CompactionView
+ percent={store.compactPercent}
+ canCompact={store.activeConversationId !== null}
+ {compactNow}
+ savePercent={saveCompactPercent}
+ />
+ {/key}
+ {:else if kind === "system-prompt"}
+ <!-- Global system prompt template. Opens a full-page modal editor (half
+ template / half variable palette). Not conversation-scoped (no {#key}). -->
+ <div class="flex flex-col gap-2">
+ <p class="text-xs opacity-60">
+ Edit the global system prompt template with variable placeholders. Opens a full-page editor.
+ </p>
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ onclick={() => (systemPromptModalOpen = true)}
+ >
+ Open builder
+ </button>
+ </div>
+ {:else if kind === "settings"}
+ <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
+ global), so the field stays mounted across tab switches. -->
+ <div class="flex flex-col gap-3">
+ <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+ </div>
+ {/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<Response> => {
- 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<Response> => {
+ 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<string, string>();
- 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<string, string>();
+ 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<typeof createAppStore>): 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<Response> => {
- 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<Response> => {
- 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<Response> => {
+ 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<Response> => {
+ 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 @@
<script lang="ts">
- /**
- * Full-screen error modal — surfaces critical errors that would otherwise be
- * silently swallowed (e.g. the cross-device tab-restore fetch failing). Shows
- * the full error text + stack trace in a scrollable `<pre>`, a Copy button
- * (clipboard), and an X to dismiss. Pure presentation: the error string and
- * dismiss callback are injected as props.
- */
- let {
- error,
- onDismiss,
- }: {
- error: string;
- onDismiss: () => void;
- } = $props();
+ /**
+ * Full-screen error modal — surfaces critical errors that would otherwise be
+ * silently swallowed (e.g. the cross-device tab-restore fetch failing). Shows
+ * the full error text + stack trace in a scrollable `<pre>`, a Copy button
+ * (clipboard), and an X to dismiss. Pure presentation: the error string and
+ * dismiss callback are injected as props.
+ */
+ let {
+ error,
+ onDismiss,
+ }: {
+ error: string;
+ onDismiss: () => void;
+ } = $props();
- let copied = $state(false);
- let copyTimer: ReturnType<typeof setTimeout> | undefined;
+ let copied = $state(false);
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
- async function handleCopy(): Promise<void> {
- try {
- await navigator.clipboard.writeText(error);
- copied = true;
- clearTimeout(copyTimer);
- copyTimer = setTimeout(() => {
- copied = false;
- }, 2000);
- } catch {
- // Clipboard API may be unavailable (non-secure context). Fallback:
- // select the text so the user can Ctrl+C manually.
- const pre = document.getElementById("error-modal-text");
- if (pre !== null) {
- const range = document.createRange();
- range.selectNodeContents(pre);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }
- }
- }
+ async function handleCopy(): Promise<void> {
+ try {
+ await navigator.clipboard.writeText(error);
+ copied = true;
+ clearTimeout(copyTimer);
+ copyTimer = setTimeout(() => {
+ copied = false;
+ }, 2000);
+ } catch {
+ // Clipboard API may be unavailable (non-secure context). Fallback:
+ // select the text so the user can Ctrl+C manually.
+ const pre = document.getElementById("error-modal-text");
+ if (pre !== null) {
+ const range = document.createRange();
+ range.selectNodeContents(pre);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }
+ }
+ }
- function handleKeydown(event: KeyboardEvent): void {
- if (event.key === "Escape") {
- event.preventDefault();
- onDismiss();
- }
- }
+ function handleKeydown(event: KeyboardEvent): void {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onDismiss();
+ }
+ }
</script>
<svelte:window onkeydown={handleKeydown} />
@@ -52,100 +52,99 @@
<!-- Full-screen overlay: fixed, high z-index, semi-transparent backdrop. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
- role="dialog"
- aria-modal="true"
- aria-label="Error"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="Error"
>
- <div class="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-base-100 shadow-xl">
- <!-- Header -->
- <div class="flex items-center justify-between border-b border-base-300 px-4 py-3">
- <div class="flex items-center gap-2">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5 text-error"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
- />
- </svg>
- <h2 class="text-lg font-semibold text-error">Something went wrong</h2>
- </div>
- <button
- type="button"
- class="btn btn-ghost btn-sm btn-square"
- aria-label="Dismiss error"
- onclick={onDismiss}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
- </svg>
- </button>
- </div>
+ <div class="flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-base-100 shadow-xl">
+ <!-- Header -->
+ <div class="flex items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5 text-error"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
+ />
+ </svg>
+ <h2 class="text-lg font-semibold text-error">Something went wrong</h2>
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ aria-label="Dismiss error"
+ onclick={onDismiss}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
- <!-- Body: scrollable error text -->
- <div class="overflow-auto p-4">
- <pre
- id="error-modal-text"
- class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed opacity-80"
- >{error}</pre>
- </div>
+ <!-- Body: scrollable error text -->
+ <div class="overflow-auto p-4">
+ <pre
+ id="error-modal-text"
+ class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed opacity-80">{error}</pre>
+ </div>
- <!-- Footer: copy button -->
- <div class="flex items-center justify-between gap-2 border-t border-base-300 px-4 py-3">
- <span class="text-xs opacity-50">Press Esc to dismiss</span>
- <button
- type="button"
- class="btn btn-sm {copied ? 'btn-success' : 'btn-outline'}"
- onclick={handleCopy}
- >
- {#if copied}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-4"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
- </svg>
- Copied
- {:else}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-4"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m18 3.75h-3.75m3.75 0-2.25 2.25m2.25-2.25 2.25 2.25M4.5 6.75 6.75 4.5"
- />
- </svg>
- Copy
- {/if}
- </button>
- </div>
- </div>
+ <!-- Footer: copy button -->
+ <div class="flex items-center justify-between gap-2 border-t border-base-300 px-4 py-3">
+ <span class="text-xs opacity-50">Press Esc to dismiss</span>
+ <button
+ type="button"
+ class="btn btn-sm {copied ? 'btn-success' : 'btn-outline'}"
+ onclick={handleCopy}
+ >
+ {#if copied}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
+ </svg>
+ Copied
+ {:else}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m18 3.75h-3.75m3.75 0-2.25 2.25m2.25-2.25 2.25 2.25M4.5 6.75 6.75 4.5"
+ />
+ </svg>
+ Copy
+ {/if}
+ </button>
+ </div>
+ </div>
</div>
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://<hostname>: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://<hostname>: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://<hostname>: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://<hostname>: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://<hostname>: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://<hostname>: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://<hostname>: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://<hostname>: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<Record<string, ModelMetadata>>;
- 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<WarmResult | null>;
- /** 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<CwdResult | null>;
- /**
- * 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<ComputerResult | null>;
- /**
- * 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<ComputerStatusResult | null>;
- /**
- * 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<TestComputerResult | null>;
- /**
- * 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<ReasoningEffortResult | null>;
- /**
- * 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<CompactResult | null>;
- /**
- * 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<CompactPercentResult | null>;
- /**
- * 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<LspResult | null>;
- /**
- * 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<McpResult | null>;
- /**
- * 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<SystemPromptLoadResult>;
- /**
- * 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<SystemPromptSaveResult>;
- /**
- * 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<SystemPromptVariablesResult>;
- /** 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<ChatLimitResult>;
- /**
- * 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<Record<string, ModelMetadata>>;
+ 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<WarmResult | null>;
+ /** 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<CwdResult | null>;
+ /**
+ * 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<ComputerResult | null>;
+ /**
+ * 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<ComputerStatusResult | null>;
+ /**
+ * 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<TestComputerResult | null>;
+ /**
+ * 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<ReasoningEffortResult | null>;
+ /**
+ * 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<CompactResult | null>;
+ /**
+ * 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<CompactPercentResult | null>;
+ /**
+ * 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<LspResult | null>;
+ /**
+ * 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<McpResult | null>;
+ /**
+ * 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<SystemPromptLoadResult>;
+ /**
+ * 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<SystemPromptSaveResult>;
+ /**
+ * 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<SystemPromptVariablesResult>;
+ /** 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<ChatLimitResult>;
+ /**
+ * 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 ([email protected]): 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 ([email protected]): 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<ProtocolState>(protocolInitialState());
- let models = $state<readonly string[]>([]);
- let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
- // 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<readonly ComputerEntry[]>([]);
- let activeModel = $state(DEFAULT_MODEL);
- let fatalError = $state<string | null>(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<string>(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<TabsState>("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<number>("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<string, ChatStore>();
-
- 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<ChatStore>(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<string | null>(null);
-
- /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
- async function refreshCwd(): Promise<void> {
- 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<string | null>(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<void> {
- 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<void> {
- 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<ReasoningEffort | null>(null);
-
- /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
- async function refreshReasoningEffort(): Promise<void> {
- 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<number | null>(null);
-
- /** Refetch the workspace conversation's compact percent (works for a draft too). */
- async function refreshCompactPercent(): Promise<void> {
- 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 ([email protected] 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<typeof createSurfaceSocket> | 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<Map<string, ConversationStatus>>(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<void> {
- 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<string, ConversationStatus>();
- 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<ModelsResponse>;
- })
- .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<ComputerListResponse>;
- })
- .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<Record<string, ModelMetadata>> {
- 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<WarmResult | null> {
- 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<CwdResult | null> {
- 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<ComputerResult | null> {
- 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<ComputerStatusResult | null> {
- 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<TestComputerResult | null> {
- 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<ReasoningEffortResult | null> {
- 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<CompactResult | null> {
- const conversationId = tabsStore.activeConversationId;
- if (conversationId === null) return null;
- const body: Record<string, unknown> = {};
- 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<CompactPercentResult | null> {
- 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<ChatLimitResult> {
- 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<LspResult | null> {
- 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<LspStatusResponse>;
- 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<McpResult | null> {
- 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<McpStatusResponse>;
- 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<SystemPromptLoadResult> {
- 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<SystemPromptSaveResult> {
- 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<SystemPromptVariablesResult> {
- 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<SystemPromptVariablesResponse>;
- 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<ProtocolState>(protocolInitialState());
+ let models = $state<readonly string[]>([]);
+ let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
+ // 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<readonly ComputerEntry[]>([]);
+ let activeModel = $state(DEFAULT_MODEL);
+ let fatalError = $state<string | null>(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<string>(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<TabsState>("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<number>("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<string, ChatStore>();
+
+ 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<ChatStore>(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<string | null>(null);
+
+ /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
+ async function refreshCwd(): Promise<void> {
+ 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<string | null>(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<void> {
+ 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<void> {
+ 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<ReasoningEffort | null>(null);
+
+ /** Refetch the workspace conversation's reasoning effort (works for a draft too). */
+ async function refreshReasoningEffort(): Promise<void> {
+ 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<number | null>(null);
+
+ /** Refetch the workspace conversation's compact percent (works for a draft too). */
+ async function refreshCompactPercent(): Promise<void> {
+ 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 ([email protected] 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<typeof createSurfaceSocket> | 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<Map<string, ConversationStatus>>(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<void> {
+ 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<string, ConversationStatus>();
+ 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<ModelsResponse>;
+ })
+ .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<ComputerListResponse>;
+ })
+ .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<Record<string, ModelMetadata>> {
+ 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<WarmResult | null> {
+ 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<CwdResult | null> {
+ 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<ComputerResult | null> {
+ 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<ComputerStatusResult | null> {
+ 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<TestComputerResult | null> {
+ 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<ReasoningEffortResult | null> {
+ 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<CompactResult | null> {
+ const conversationId = tabsStore.activeConversationId;
+ if (conversationId === null) return null;
+ const body: Record<string, unknown> = {};
+ 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<CompactPercentResult | null> {
+ 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<ChatLimitResult> {
+ 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<LspResult | null> {
+ 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<LspStatusResponse>;
+ 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<McpResult | null> {
+ 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<McpStatusResponse>;
+ 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<SystemPromptLoadResult> {
+ 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<SystemPromptSaveResult> {
+ 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<SystemPromptVariablesResult> {
+ 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<SystemPromptVariablesResponse>;
+ 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<string, ConversationHistoryResponse>;
- model?: string | null;
+ models?: readonly string[];
+ history?: Record<string, ConversationHistoryResponse>;
+ 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<Response> => {
- 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<Response> => {
+ 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<string, string>();
- 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<string, string>();
+ 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<typeof createAppStore>): 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<Response> => {
- 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<Response> => {
- 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<Response> => {
+ 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<Response> => {
+ 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<string>();
- 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<string>();
+ 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 @@
<script lang="ts">
- // Generic, purely presentational table. Props in → markup out; zero logic,
- // zero data-fetching. Shared by the surface custom-field "table" renderer and
- // the frontend "Loaded Modules" view, so neither feature depends on the other.
- let {
- columns,
- rows,
- empty = "No data",
- }: {
- readonly columns: readonly string[];
- readonly rows: readonly (readonly string[])[];
- /** Text shown when there are no rows. */
- readonly empty?: string;
- } = $props();
+ // Generic, purely presentational table. Props in → markup out; zero logic,
+ // zero data-fetching. Shared by the surface custom-field "table" renderer and
+ // the frontend "Loaded Modules" view, so neither feature depends on the other.
+ let {
+ columns,
+ rows,
+ empty = "No data",
+ }: {
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
+ /** Text shown when there are no rows. */
+ readonly empty?: string;
+ } = $props();
</script>
<div class="overflow-x-auto">
- <table class="table table-sm">
- <thead>
- <tr>
- {#each columns as col, i (i)}
- <th>{col}</th>
- {/each}
- </tr>
- </thead>
- <tbody>
- {#if rows.length === 0}
- <tr>
- <td colspan={Math.max(columns.length, 1)} class="opacity-60">{empty}</td>
- </tr>
- {:else}
- {#each rows as row, r (r)}
- <tr>
- {#each row as cell, c (c)}
- <td>{cell}</td>
- {/each}
- </tr>
- {/each}
- {/if}
- </tbody>
- </table>
+ <table class="table table-sm">
+ <thead>
+ <tr>
+ {#each columns as col, i (i)}
+ <th>{col}</th>
+ {/each}
+ </tr>
+ </thead>
+ <tbody>
+ {#if rows.length === 0}
+ <tr>
+ <td colspan={Math.max(columns.length, 1)} class="opacity-60">{empty}</td>
+ </tr>
+ {:else}
+ {#each rows as row, r (r)}
+ <tr>
+ {#each row as cell, c (c)}
+ <td>{cell}</td>
+ {/each}
+ </tr>
+ {/each}
+ {/if}
+ </tbody>
+ </table>
</div>
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<string, number>();
- 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<string>();
- for (const [stepId, count] of callsPerStep) {
- if (count >= 2) batchSteps.add(stepId);
- }
+ // 1. Steps that batched 2+ tool calls.
+ const callsPerStep = new Map<string, number>();
+ 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<string>();
+ 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<string>();
- const resultByCallId = new Map<string, ToolResultChunk>();
- 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<string>();
+ const resultByCallId = new Map<string, ToolResultChunk>();
+ 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<string>();
- 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<string>();
+ 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<number, StoredChunk>();
- 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<number, StoredChunk>();
+ 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<AgentEvent["type"]> = 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<MetricsRow, { kind: "turn-metrics" }> => {
- 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<MetricsRow, { kind: "turn-metrics" }> => {
+ 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<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId)));
+ // Build stepId → entry-index lookup for matching.
+ const entryStepIds: Set<string>[] = 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<number>();
- const segmentEntry = new Map<number, TurnMetricsEntry>();
- const segmentEntryIndex = new Map<number, number>();
+ // 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<number>();
+ const segmentEntry = new Map<number, TurnMetricsEntry>();
+ const segmentEntryIndex = new Map<number, number>();
- // 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<string>();
- 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<string>();
+ 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<string, number>();
- 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<string, number>();
+ 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<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> =
- 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<number, { stepIndex: number; step: (typeof entry.steps)[number] }[]> =
+ 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<BuildingStep>): 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<string>();
+ const result: TurnMetricsEntry[] = [];
+ const seen = new Set<string>();
- 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<string, BuildingStep>;
- 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<string, BuildingStep>;
+ 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<string, LiveTurn>;
- readonly liveOrder: readonly string[];
- readonly durable: ReadonlyMap<string, TurnMetrics>;
- readonly durableOrder: readonly string[];
+ readonly live: ReadonlyMap<string, LiveTurn>;
+ readonly liveOrder: readonly string[];
+ readonly durable: ReadonlyMap<string, TurnMetrics>;
+ 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<string, Subscription>;
- /** 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<string, Subscription>;
+ /** 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<WarmFeedback | null>;
// ── 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<string, unknown>;
- 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<string, unknown>;
+ 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 @@
<script lang="ts">
- import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { onMount, untrack } from "svelte";
- import {
- clampMinutes,
- clampSeconds,
- colorClass,
- formatCountdown,
- formatWarmLabel,
- fromMinSec,
- initialWarmingState,
- observeWarm,
- parseControls,
- secondsUntilNext,
- statusForPct,
- toMinSec,
- type WarmingViewState,
- type WarmNow,
- } from "../logic/view-model";
-
- let {
- spec,
- canWarm,
- onInvoke,
- warmNow,
- }: {
- /** The cache-warming surface spec for the focused conversation, or null. */
- spec: SurfaceSpec | null;
- /** Whether a real conversation is focused (a draft has nothing to warm). */
- canWarm: boolean;
- onInvoke: (msg: InvokeMessage) => void;
- warmNow: WarmNow;
- } = $props();
-
- const controls = $derived(parseControls(spec));
-
- // View-model state (pure reducer) + the injected clock — owned here, not ambient.
- let vm = $state<WarmingViewState>(initialWarmingState());
- let now = $state(Date.now());
- let warming = $state(false);
- let errorText = $state<string | null>(null);
- // Transient result of the most recent manual warm (immediate feedback; history
- // itself is driven authoritatively by the surface's `lastWarmAt`).
- let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null);
-
- // Local interval inputs, seeded from the surface and re-seeded only when the
- // surface's interval differs from what's shown (so a stray update mid-edit
- // doesn't clobber typing).
- let minutes = $state(0);
- let seconds = $state(0);
-
- onMount(() => {
- const id = setInterval(() => {
- now = Date.now();
- }, 1000);
- return () => clearInterval(id);
- });
-
- // Fold each authoritative warm (new `lastWarmAt`) into history.
- $effect(() => {
- const at = controls.lastWarmAt;
- const pct = controls.lastPct;
- untrack(() => {
- vm = observeWarm(vm, at, pct);
- });
- });
-
- // Keep the min/sec inputs in sync with the surface's interval.
- $effect(() => {
- const target = controls.intervalSeconds;
- untrack(() => {
- if (fromMinSec(minutes, seconds) !== target) {
- const ms = toMinSec(target);
- minutes = ms.minutes;
- seconds = ms.seconds;
- }
- });
- });
-
- const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now));
- const history = $derived(vm.history);
- const latest = $derived(history[0] ?? null);
- const earlier = $derived(history.slice(1));
-
- function commitInterval() {
- const actionId = controls.setIntervalActionId;
- if (actionId === null || spec === null) return;
- onInvoke({ type: "invoke", surfaceId: spec.id, actionId, payload: fromMinSec(minutes, seconds) });
- }
-
- function onMinutes(event: Event) {
- const next = (event.target as HTMLInputElement).valueAsNumber;
- if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
- minutes = clampMinutes(next);
- commitInterval();
- }
-
- function onSeconds(event: Event) {
- const next = (event.target as HTMLInputElement).valueAsNumber;
- if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
- seconds = clampSeconds(next);
- commitInterval();
- }
-
- function onToggle() {
- const actionId = controls.toggleActionId;
- if (actionId === null || spec === null) return;
- // The toggle action FLIPS server-side; no payload.
- onInvoke({ type: "invoke", surfaceId: spec.id, actionId });
- }
-
- async function handleWarm() {
- if (warming) return;
- warming = true;
- errorText = null;
- const result = await warmNow();
- warming = false;
- if (result === null) return;
- if (result.ok) {
- // Immediate feedback only — the authoritative surface `update` (new
- // `lastWarmAt`) drives the history via `observeWarm`.
- manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate };
- } else {
- manualResult = null;
- errorText = result.error;
- }
- }
+ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+ import { onMount, untrack } from "svelte";
+ import {
+ clampMinutes,
+ clampSeconds,
+ colorClass,
+ formatCountdown,
+ formatWarmLabel,
+ fromMinSec,
+ initialWarmingState,
+ observeWarm,
+ parseControls,
+ secondsUntilNext,
+ statusForPct,
+ toMinSec,
+ type WarmingViewState,
+ type WarmNow,
+ } from "../logic/view-model";
+
+ let {
+ spec,
+ canWarm,
+ onInvoke,
+ warmNow,
+ }: {
+ /** The cache-warming surface spec for the focused conversation, or null. */
+ spec: SurfaceSpec | null;
+ /** Whether a real conversation is focused (a draft has nothing to warm). */
+ canWarm: boolean;
+ onInvoke: (msg: InvokeMessage) => void;
+ warmNow: WarmNow;
+ } = $props();
+
+ const controls = $derived(parseControls(spec));
+
+ // View-model state (pure reducer) + the injected clock — owned here, not ambient.
+ let vm = $state<WarmingViewState>(initialWarmingState());
+ let now = $state(Date.now());
+ let warming = $state(false);
+ let errorText = $state<string | null>(null);
+ // Transient result of the most recent manual warm (immediate feedback; history
+ // itself is driven authoritatively by the surface's `lastWarmAt`).
+ let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null);
+
+ // Local interval inputs, seeded from the surface and re-seeded only when the
+ // surface's interval differs from what's shown (so a stray update mid-edit
+ // doesn't clobber typing).
+ let minutes = $state(0);
+ let seconds = $state(0);
+
+ onMount(() => {
+ const id = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(id);
+ });
+
+ // Fold each authoritative warm (new `lastWarmAt`) into history.
+ $effect(() => {
+ const at = controls.lastWarmAt;
+ const pct = controls.lastPct;
+ untrack(() => {
+ vm = observeWarm(vm, at, pct);
+ });
+ });
+
+ // Keep the min/sec inputs in sync with the surface's interval.
+ $effect(() => {
+ const target = controls.intervalSeconds;
+ untrack(() => {
+ if (fromMinSec(minutes, seconds) !== target) {
+ const ms = toMinSec(target);
+ minutes = ms.minutes;
+ seconds = ms.seconds;
+ }
+ });
+ });
+
+ const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now));
+ const history = $derived(vm.history);
+ const latest = $derived(history[0] ?? null);
+ const earlier = $derived(history.slice(1));
+
+ function commitInterval() {
+ const actionId = controls.setIntervalActionId;
+ if (actionId === null || spec === null) return;
+ onInvoke({
+ type: "invoke",
+ surfaceId: spec.id,
+ actionId,
+ payload: fromMinSec(minutes, seconds),
+ });
+ }
+
+ function onMinutes(event: Event) {
+ const next = (event.target as HTMLInputElement).valueAsNumber;
+ if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
+ minutes = clampMinutes(next);
+ commitInterval();
+ }
+
+ function onSeconds(event: Event) {
+ const next = (event.target as HTMLInputElement).valueAsNumber;
+ if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
+ seconds = clampSeconds(next);
+ commitInterval();
+ }
+
+ function onToggle() {
+ const actionId = controls.toggleActionId;
+ if (actionId === null || spec === null) return;
+ // The toggle action FLIPS server-side; no payload.
+ onInvoke({ type: "invoke", surfaceId: spec.id, actionId });
+ }
+
+ async function handleWarm() {
+ if (warming) return;
+ warming = true;
+ errorText = null;
+ const result = await warmNow();
+ warming = false;
+ if (result === null) return;
+ if (result.ok) {
+ // Immediate feedback only — the authoritative surface `update` (new
+ // `lastWarmAt`) drives the history via `observeWarm`.
+ manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate };
+ } else {
+ manualResult = null;
+ errorText = result.error;
+ }
+ }
</script>
<div class="flex flex-col gap-3">
- <!-- Enabled -->
- <label class="flex items-center justify-between gap-2 text-sm">
- <span>Enabled</span>
- <input
- type="checkbox"
- class="toggle toggle-sm toggle-success"
- checked={controls.enabled}
- disabled={spec === null}
- onchange={onToggle}
- />
- </label>
-
- <!-- Refresh interval: minutes + seconds (seconds capped at 59) -->
- <div class="flex items-center justify-between gap-2 text-sm">
- <span>Refresh interval</span>
- <span class="flex items-center gap-1">
- <input
- type="number"
- class="input input-bordered input-sm w-16"
- min="0"
- value={minutes}
- disabled={spec === null}
- onchange={onMinutes}
- aria-label="Interval minutes"
- />
- <span class="opacity-60">m</span>
- <input
- type="number"
- class="input input-bordered input-sm w-16"
- min="0"
- max="59"
- value={seconds}
- disabled={spec === null}
- onchange={onSeconds}
- aria-label="Interval seconds"
- />
- <span class="opacity-60">s</span>
- </span>
- </div>
-
- <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) -->
- {#if !controls.enabled}
- <p class="text-xs opacity-50">Warming paused.</p>
- {:else if remaining !== null}
- <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p>
- {:else}
- <p class="text-xs opacity-50">Next warm: waiting…</p>
- {/if}
-
- <!-- Cross-turn retention (the "is warming working?" health signal) -->
- {#if controls.retentionPct !== null}
- <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}">
- Cache retention: {controls.retentionPct}%
- </p>
- {/if}
-
- <!-- Manual trigger -->
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={!canWarm || warming}
- onclick={handleWarm}
- >
- {#if warming}
- <span class="loading loading-spinner loading-xs"></span>
- Warming…
- {:else}
- Warm now
- {/if}
- </button>
-
- {#if !canWarm}
- <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p>
- {:else if errorText}
- <p class="text-xs text-error">{errorText}</p>
- {:else if manualResult}
- <!-- Headline the retention (cache health) over the raw hit %. -->
- <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}">
- Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached)
- </p>
- {/if}
-
- <!-- Warming history: collapse whose title is the most recent warm, coloured by
- hit %, with the earlier warmings inside. -->
- {#if latest}
- <div class="collapse collapse-arrow bg-base-200">
- <input type="checkbox" aria-label="Toggle warming history" />
- <div class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(statusForPct(latest.pct))}">
- {formatWarmLabel(latest.pct)}
- </div>
- <div class="collapse-content flex flex-col gap-1 text-sm">
- {#if earlier.length > 0}
- {#each earlier as entry, i (i)}
- <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p>
- {/each}
- {:else}
- <p class="text-xs opacity-60">No earlier warmings.</p>
- {/if}
- </div>
- </div>
- {:else}
- <p class="text-xs opacity-60">No warming yet.</p>
- {/if}
+ <!-- Enabled -->
+ <label class="flex items-center justify-between gap-2 text-sm">
+ <span>Enabled</span>
+ <input
+ type="checkbox"
+ class="toggle toggle-sm toggle-success"
+ checked={controls.enabled}
+ disabled={spec === null}
+ onchange={onToggle}
+ />
+ </label>
+
+ <!-- Refresh interval: minutes + seconds (seconds capped at 59) -->
+ <div class="flex items-center justify-between gap-2 text-sm">
+ <span>Refresh interval</span>
+ <span class="flex items-center gap-1">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-16"
+ min="0"
+ value={minutes}
+ disabled={spec === null}
+ onchange={onMinutes}
+ aria-label="Interval minutes"
+ />
+ <span class="opacity-60">m</span>
+ <input
+ type="number"
+ class="input input-bordered input-sm w-16"
+ min="0"
+ max="59"
+ value={seconds}
+ disabled={spec === null}
+ onchange={onSeconds}
+ aria-label="Interval seconds"
+ />
+ <span class="opacity-60">s</span>
+ </span>
+ </div>
+
+ <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) -->
+ {#if !controls.enabled}
+ <p class="text-xs opacity-50">Warming paused.</p>
+ {:else if remaining !== null}
+ <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p>
+ {:else}
+ <p class="text-xs opacity-50">Next warm: waiting…</p>
+ {/if}
+
+ <!-- Cross-turn retention (the "is warming working?" health signal) -->
+ {#if controls.retentionPct !== null}
+ <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}">
+ Cache retention: {controls.retentionPct}%
+ </p>
+ {/if}
+
+ <!-- Manual trigger -->
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canWarm || warming}
+ onclick={handleWarm}
+ >
+ {#if warming}
+ <span class="loading loading-spinner loading-xs"></span>
+ Warming…
+ {:else}
+ Warm now
+ {/if}
+ </button>
+
+ {#if !canWarm}
+ <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p>
+ {:else if errorText}
+ <p class="text-xs text-error">{errorText}</p>
+ {:else if manualResult}
+ <!-- Headline the retention (cache health) over the raw hit %. -->
+ <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}">
+ Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt
+ cached)
+ </p>
+ {/if}
+
+ <!-- Warming history: collapse whose title is the most recent warm, coloured by
+ hit %, with the earlier warmings inside. -->
+ {#if latest}
+ <div class="collapse collapse-arrow bg-base-200">
+ <input type="checkbox" aria-label="Toggle warming history" />
+ <div
+ class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(
+ statusForPct(latest.pct),
+ )}"
+ >
+ {formatWarmLabel(latest.pct)}
+ </div>
+ <div class="collapse-content flex flex-col gap-1 text-sm">
+ {#if earlier.length > 0}
+ {#each earlier as entry, i (i)}
+ <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p>
+ {/each}
+ {:else}
+ <p class="text-xs opacity-60">No earlier warmings.</p>
+ {/if}
+ </div>
+ </div>
+ {:else}
+ <p class="text-xs opacity-60">No warming yet.</p>
+ {/if}
</div>
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 `<key>/<model>` 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 `<key>/<model>` 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<string>();
- 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<string>();
+ 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<ConversationHistoryResponse>;
/** 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 (`[email protected]`). */
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 `<select>` value) to the ladder. */
export function isReasoningEffort(value: string): value is ReasoningEffort {
- return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value);
+ return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
@@ -33,13 +33,13 @@ export function isReasoningEffort(value: string): value is ReasoningEffort {
* server default when never set (`null` = "default applies", not "off").
*/
export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort {
- return persisted ?? DEFAULT_REASONING_EFFORT;
+ return persisted ?? DEFAULT_REASONING_EFFORT;
}
/** One `<option>` of the selector. */
export interface EffortOption {
- readonly value: ReasoningEffort;
- readonly label: string;
+ readonly value: ReasoningEffort;
+ readonly label: string;
}
/**
@@ -47,10 +47,10 @@ export interface EffortOption {
* `(default)` so a never-set conversation reads "high (default)".
*/
export function effortOptions(): readonly EffortOption[] {
- return REASONING_EFFORT_LEVELS.map((level) => ({
- value: level,
- label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level,
- }));
+ return REASONING_EFFORT_LEVELS.map((level) => ({
+ value: level,
+ label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level,
+ }));
}
// ── Injected port (consumer-defines-port; the composition root adapts the
@@ -58,9 +58,9 @@ export function effortOptions(): readonly EffortOption[] {
/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
export type ReasoningEffortSaveResult =
- | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
export type SaveReasoningEffort = (
- level: ReasoningEffort,
+ level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index a31fe55..3588da1 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,398 +1,398 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ChatQueueMessage,
- ChatSendMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
import {
- appendUserMessage,
- applyHistory,
- clearGenerating,
- foldEvent,
- initialState,
- initialWindowSize,
- normalizeChatLimit,
- restoreEarlier,
- selectChunks,
- selectGenerating,
- selectHasEarlier,
- selectMessages,
- selectProviderRetry,
- trimTranscript,
- unloadCount,
- windowTranscript,
+ appendUserMessage,
+ applyHistory,
+ clearGenerating,
+ foldEvent,
+ initialState,
+ initialWindowSize,
+ normalizeChatLimit,
+ restoreEarlier,
+ selectChunks,
+ selectGenerating,
+ selectHasEarlier,
+ selectMessages,
+ selectProviderRetry,
+ trimTranscript,
+ unloadCount,
+ windowTranscript,
} from "../../core/chunks";
import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
import {
- applyDurableMetrics,
- foldMetricsEvent,
- initialMetricsState,
- selectCurrentContextSize,
- selectOrderedTurnMetrics,
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectCurrentContextSize,
+ selectOrderedTurnMetrics,
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
- readonly conversationId: string;
- readonly model?: string;
- readonly transport: ChatTransport;
- readonly historySync: HistorySync;
- readonly metricsSync: MetricsSync;
- readonly cache: ConversationCache;
- /**
- * The workspace this conversation belongs to (its URL slug). Sent on
- * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
- * at creation (default "default"). Absent → omitted (legacy behavior).
- */
- readonly workspaceId?: string;
- /**
- * The chat limit: max loaded chunks before the oldest quarter is unloaded
- * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
- * `DEFAULT_CHAT_LIMIT`.
- */
- readonly chatLimit?: number;
- /**
- * Whether unloading may run RIGHT NOW. The composition root wires this to the
- * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a
- * trim would yank the content under them, so it is DEFERRED until they return
- * to the bottom (the next fold retries). Absent → always allowed.
- */
- readonly canUnload?: () => boolean;
- /**
- * Called when a swallowed error should be surfaced to the user (e.g. a
- * metrics sync failure). Wired by the composition root to the app store's
- * `reportError` → full-screen error modal. Absent → errors are logged only.
- */
- readonly onError?: (context: string, err: unknown) => void;
+ readonly conversationId: string;
+ readonly model?: string;
+ readonly transport: ChatTransport;
+ readonly historySync: HistorySync;
+ readonly metricsSync: MetricsSync;
+ readonly cache: ConversationCache;
+ /**
+ * The workspace this conversation belongs to (its URL slug). Sent on
+ * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
+ * at creation (default "default"). Absent → omitted (legacy behavior).
+ */
+ readonly workspaceId?: string;
+ /**
+ * The chat limit: max loaded chunks before the oldest quarter is unloaded
+ * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
+ * `DEFAULT_CHAT_LIMIT`.
+ */
+ readonly chatLimit?: number;
+ /**
+ * Whether unloading may run RIGHT NOW. The composition root wires this to the
+ * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a
+ * trim would yank the content under them, so it is DEFERRED until they return
+ * to the bottom (the next fold retries). Absent → always allowed.
+ */
+ readonly canUnload?: () => boolean;
+ /**
+ * Called when a swallowed error should be surfaced to the user (e.g. a
+ * metrics sync failure). Wired by the composition root to the app store's
+ * `reportError` → full-screen error modal. Absent → errors are logged only.
+ */
+ readonly onError?: (context: string, err: unknown) => void;
}
export interface ChatStore {
- readonly messages: readonly ChatMessage[];
- readonly chunks: readonly RenderedChunk[];
- readonly turnMetrics: readonly TurnMetricsEntry[];
- /**
- * The conversation's current context size (tokens occupied) — the latest
- * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
- * known yet. Never `0` for the unknown case.
- */
- readonly currentContextSize: number | undefined;
- /**
- * Whether a turn is currently generating server-side — derived from the event
- * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching
- * client: the sender, a second device, or a reconnected client whose in-flight
- * turn was replayed. Drives the composer's "generating…" indicator.
- */
- readonly generating: boolean;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when no
- * retry is pending. Drives the transient yellow "retrying…" warning banner —
- * never persisted (never sent to the model or replayed on reload). Coalesces
- * to the newest attempt + delay; cleared when content resumes or the turn ends.
- */
- readonly providerRetry: TurnProviderRetryEvent | null;
- readonly pendingSync: boolean;
- readonly error: string | null;
- readonly model: string | undefined;
- /**
- * Whether earlier history was unloaded by the chat limit (or never loaded by
- * the fresh-load window) and can be paged back in — drives the
- * "Show earlier messages" affordance.
- */
- readonly hasEarlier: boolean;
- /**
- * Render-key base for thinking collapses: how many thinking chunks are
- * unloaded below the watermark, so the UI's ordinal keys stay stable across
- * a trim (see `TranscriptState.hiddenThinkingCount`).
- */
- readonly thinkingKeyBase: number;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
- send(text: string): void;
- /**
- * Enqueue a steering message onto the conversation's queue (`chat.queue`
- * WS op). While a turn is generating, the message is delivered mid-turn at
- * the next tool-result boundary (a `steering` `AgentEvent` fires + the
- * message-queue surface updates). When no turn is active, the server
- * auto-starts a turn with the message as its opening prompt (equivalent to
- * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
- * pending message until drain; the `steering` event places it in the
- * transcript. `text` must be non-empty (the server 400/errors otherwise).
- */
- queueMessage(text: string): void;
- setModel(model: string): void;
- /**
- * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
- * Lowering it unloads older committed chunks (deferred via the gate while the
- * reader is scrolled up, catching up on the next mutation). Raising it
- * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the
- * fresh-load window (`initialWindowSize` = 75% of the limit) — the same
- * window a fresh `load()` would show — so upping the limit reveals more
- * history instead of leaving a partial view. New deltas + loads use the new
- * limit. The refill awaits, so a caller can preserve scroll over the prepend.
- */
- setChatLimit(limit: number): Promise<void>;
- load(): Promise<void>;
- /**
- * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the
- * "Show earlier messages" action. Local cache first; when the cache doesn't
- * reach far enough back (a server-windowed fresh load), the missing older
- * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache.
- */
- showEarlier(): Promise<void>;
- /**
- * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may
- * have sealed while disconnected — the live `turn-sealed` was missed), then
- * pulls newly-sealed turns from history (+ metrics). If the turn is still
- * running, the server's post-subscribe replay re-asserts `generating`. The
- * app store pairs this with a `chat.subscribe` for the conversation.
- */
- resync(): void;
- dispose(): void;
+ readonly messages: readonly ChatMessage[];
+ readonly chunks: readonly RenderedChunk[];
+ readonly turnMetrics: readonly TurnMetricsEntry[];
+ /**
+ * The conversation's current context size (tokens occupied) — the latest
+ * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
+ * known yet. Never `0` for the unknown case.
+ */
+ readonly currentContextSize: number | undefined;
+ /**
+ * Whether a turn is currently generating server-side — derived from the event
+ * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching
+ * client: the sender, a second device, or a reconnected client whose in-flight
+ * turn was replayed. Drives the composer's "generating…" indicator.
+ */
+ readonly generating: boolean;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when no
+ * retry is pending. Drives the transient yellow "retrying…" warning banner —
+ * never persisted (never sent to the model or replayed on reload). Coalesces
+ * to the newest attempt + delay; cleared when content resumes or the turn ends.
+ */
+ readonly providerRetry: TurnProviderRetryEvent | null;
+ readonly pendingSync: boolean;
+ readonly error: string | null;
+ readonly model: string | undefined;
+ /**
+ * Whether earlier history was unloaded by the chat limit (or never loaded by
+ * the fresh-load window) and can be paged back in — drives the
+ * "Show earlier messages" affordance.
+ */
+ readonly hasEarlier: boolean;
+ /**
+ * Render-key base for thinking collapses: how many thinking chunks are
+ * unloaded below the watermark, so the UI's ordinal keys stay stable across
+ * a trim (see `TranscriptState.hiddenThinkingCount`).
+ */
+ readonly thinkingKeyBase: number;
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ send(text: string): void;
+ /**
+ * Enqueue a steering message onto the conversation's queue (`chat.queue`
+ * WS op). While a turn is generating, the message is delivered mid-turn at
+ * the next tool-result boundary (a `steering` `AgentEvent` fires + the
+ * message-queue surface updates). When no turn is active, the server
+ * auto-starts a turn with the message as its opening prompt (equivalent to
+ * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
+ * pending message until drain; the `steering` event places it in the
+ * transcript. `text` must be non-empty (the server 400/errors otherwise).
+ */
+ queueMessage(text: string): void;
+ setModel(model: string): void;
+ /**
+ * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
+ * Lowering it unloads older committed chunks (deferred via the gate while the
+ * reader is scrolled up, catching up on the next mutation). Raising it
+ * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the
+ * fresh-load window (`initialWindowSize` = 75% of the limit) — the same
+ * window a fresh `load()` would show — so upping the limit reveals more
+ * history instead of leaving a partial view. New deltas + loads use the new
+ * limit. The refill awaits, so a caller can preserve scroll over the prepend.
+ */
+ setChatLimit(limit: number): Promise<void>;
+ load(): Promise<void>;
+ /**
+ * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the
+ * "Show earlier messages" action. Local cache first; when the cache doesn't
+ * reach far enough back (a server-windowed fresh load), the missing older
+ * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache.
+ */
+ showEarlier(): Promise<void>;
+ /**
+ * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may
+ * have sealed while disconnected — the live `turn-sealed` was missed), then
+ * pulls newly-sealed turns from history (+ metrics). If the turn is still
+ * running, the server's post-subscribe replay re-asserts `generating`. The
+ * app store pairs this with a `chat.subscribe` for the conversation.
+ */
+ resync(): void;
+ dispose(): void;
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
- let transcript = $state<TranscriptState>(initialState());
- let metrics = $state<MetricsState>(initialMetricsState());
- let _pendingSync = $state(false);
- let _error = $state<string | null>(null);
- let _model = $state<string | undefined>(deps.model);
- let disposed = false;
+ let transcript = $state<TranscriptState>(initialState());
+ let metrics = $state<MetricsState>(initialMetricsState());
+ let _pendingSync = $state(false);
+ let _error = $state<string | null>(null);
+ let _model = $state<string | undefined>(deps.model);
+ let disposed = false;
- let chatLimit = normalizeChatLimit(deps.chatLimit);
+ let chatLimit = normalizeChatLimit(deps.chatLimit);
- /**
- * Enforce the chat limit after a transcript mutation — unless the injected
- * gate says the reader is scrolled up (then defer; the next mutation retries
- * and `trimTranscript` unloads whole quarters to catch up).
- */
- function maybeTrim(): void {
- if (deps.canUnload !== undefined && !deps.canUnload()) return;
- transcript = trimTranscript(transcript, chatLimit);
- }
+ /**
+ * Enforce the chat limit after a transcript mutation — unless the injected
+ * gate says the reader is scrolled up (then defer; the next mutation retries
+ * and `trimTranscript` unloads whole quarters to catch up).
+ */
+ function maybeTrim(): void {
+ if (deps.canUnload !== undefined && !deps.canUnload()) return;
+ transcript = trimTranscript(transcript, chatLimit);
+ }
- /**
- * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when
- * given AND the cache is empty (a truly fresh browser), windows the fetch to
- * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship
- * whole. It is deliberately NOT applied to a warm-cache tail: windowing a
- * tail that grew past N while we were away would leave a silent seq GAP
- * between the cache and the fetched window.
- */
- async function syncTail(coldLimit?: number): Promise<void> {
- if (disposed || _pendingSync) return;
- _pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
- const res = await deps.historySync(deps.conversationId, since, window);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- maybeTrim();
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
- }
- }
+ /**
+ * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when
+ * given AND the cache is empty (a truly fresh browser), windows the fetch to
+ * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship
+ * whole. It is deliberately NOT applied to a warm-cache tail: windowing a
+ * tail that grew past N while we were away would leave a silent seq GAP
+ * between the cache and the fetched window.
+ */
+ async function syncTail(coldLimit?: number): Promise<void> {
+ if (disposed || _pendingSync) return;
+ _pendingSync = true;
+ try {
+ const since = await deps.cache.sinceSeq(deps.conversationId);
+ const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
+ const res = await deps.historySync(deps.conversationId, since, window);
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ transcript = applyHistory(transcript, merged);
+ maybeTrim();
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ } finally {
+ _pendingSync = false;
+ }
+ }
- async function syncMetrics(): Promise<void> {
- if (disposed) return;
- try {
- const res = await deps.metricsSync(deps.conversationId);
- metrics = applyDurableMetrics(metrics, res.turns);
- } catch (err) {
- // Metrics fetch failure must not block history sync or throw;
- // live-folded metrics remain intact. Surface via onError (modal).
- console.error("[syncMetrics] failed:", err);
- deps.onError?.("Failed to sync conversation metrics", err);
- }
- }
+ async function syncMetrics(): Promise<void> {
+ if (disposed) return;
+ try {
+ const res = await deps.metricsSync(deps.conversationId);
+ metrics = applyDurableMetrics(metrics, res.turns);
+ } catch (err) {
+ // Metrics fetch failure must not block history sync or throw;
+ // live-folded metrics remain intact. Surface via onError (modal).
+ console.error("[syncMetrics] failed:", err);
+ deps.onError?.("Failed to sync conversation metrics", err);
+ }
+ }
- /**
- * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a
- * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow,
- * persisting it so the next read is local. Returns every locally-known
- * chunk older than `oldest` (the caller — `restoreEarlier` — takes the
- * newest `count` of them). Shared by `showEarlier` and the raise-refill.
- */
- async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
- let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
- const oldestKnown = earlier[0]?.seq ?? oldest;
- if (earlier.length < want && oldestKnown > 1) {
- const res = await deps.historySync(deps.conversationId, 0, {
- beforeSeq: oldestKnown,
- limit: want - earlier.length,
- });
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- earlier = merged.filter((c) => c.seq < oldest);
- }
- return earlier;
- }
+ /**
+ * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a
+ * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow,
+ * persisting it so the next read is local. Returns every locally-known
+ * chunk older than `oldest` (the caller — `restoreEarlier` — takes the
+ * newest `count` of them). Shared by `showEarlier` and the raise-refill.
+ */
+ async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
+ let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
+ const oldestKnown = earlier[0]?.seq ?? oldest;
+ if (earlier.length < want && oldestKnown > 1) {
+ const res = await deps.historySync(deps.conversationId, 0, {
+ beforeSeq: oldestKnown,
+ limit: want - earlier.length,
+ });
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ earlier = merged.filter((c) => c.seq < oldest);
+ }
+ return earlier;
+ }
- /**
- * Refill toward the fresh-load window after a limit RAISE: pull older
- * history (cache first, then server) so the loaded set grows to match what a
- * fresh `load()` would show at the new limit. No-op when already at the
- * origin (seq 1) or already within the window. `restoreEarlier` re-derives
- * the window start at apply time, so a delta landing during the await can't
- * corrupt the merge. NOT gated (refilling prepends above the viewport; the
- * caller preserves scroll position).
- */
- async function refill(): Promise<void> {
- if (disposed) return;
- const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
- const want = initialWindowSize(chatLimit) - transcript.committed.length;
- if (want <= 0) return;
- try {
- const earlier = await backfillOlder(oldest, want);
- if (earlier.length === 0) return;
- transcript = restoreEarlier(transcript, earlier, want);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- }
- }
+ /**
+ * Refill toward the fresh-load window after a limit RAISE: pull older
+ * history (cache first, then server) so the loaded set grows to match what a
+ * fresh `load()` would show at the new limit. No-op when already at the
+ * origin (seq 1) or already within the window. `restoreEarlier` re-derives
+ * the window start at apply time, so a delta landing during the await can't
+ * corrupt the merge. NOT gated (refilling prepends above the viewport; the
+ * caller preserves scroll position).
+ */
+ async function refill(): Promise<void> {
+ if (disposed) return;
+ const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
+ if (oldest <= 1) return;
+ const want = initialWindowSize(chatLimit) - transcript.committed.length;
+ if (want <= 0) return;
+ try {
+ const earlier = await backfillOlder(oldest, want);
+ if (earlier.length === 0) return;
+ transcript = restoreEarlier(transcript, earlier, want);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ }
- return {
- get messages(): readonly ChatMessage[] {
- return selectMessages(transcript);
- },
- get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
- },
- get turnMetrics(): readonly TurnMetricsEntry[] {
- return selectOrderedTurnMetrics(metrics);
- },
- get currentContextSize(): number | undefined {
- return selectCurrentContextSize(metrics);
- },
- get generating(): boolean {
- return selectGenerating(transcript);
- },
- get providerRetry(): TurnProviderRetryEvent | null {
- return selectProviderRetry(transcript);
- },
- get pendingSync(): boolean {
- return _pendingSync;
- },
- get error(): string | null {
- return _error;
- },
- get model(): string | undefined {
- return _model;
- },
- get hasEarlier(): boolean {
- return selectHasEarlier(transcript);
- },
- get thinkingKeyBase(): number {
- return transcript.hiddenThinkingCount;
- },
+ return {
+ get messages(): readonly ChatMessage[] {
+ return selectMessages(transcript);
+ },
+ get chunks(): readonly RenderedChunk[] {
+ return selectChunks(transcript);
+ },
+ get turnMetrics(): readonly TurnMetricsEntry[] {
+ return selectOrderedTurnMetrics(metrics);
+ },
+ get currentContextSize(): number | undefined {
+ return selectCurrentContextSize(metrics);
+ },
+ get generating(): boolean {
+ return selectGenerating(transcript);
+ },
+ get providerRetry(): TurnProviderRetryEvent | null {
+ return selectProviderRetry(transcript);
+ },
+ get pendingSync(): boolean {
+ return _pendingSync;
+ },
+ get error(): string | null {
+ return _error;
+ },
+ get model(): string | undefined {
+ return _model;
+ },
+ get hasEarlier(): boolean {
+ return selectHasEarlier(transcript);
+ },
+ get thinkingKeyBase(): number {
+ return transcript.hiddenThinkingCount;
+ },
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
- if (msg.type === "chat.error") {
- if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
- return;
- }
- _error = msg.message;
- return;
- }
- if (msg.event.conversationId !== deps.conversationId) {
- return;
- }
- transcript = foldEvent(transcript, msg.event);
- metrics = foldMetricsEvent(metrics, msg.event);
- maybeTrim();
- if (transcript.sealedTurnId !== null) {
- void syncTail();
- void syncMetrics();
- }
- },
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ if (msg.type === "chat.error") {
+ if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
+ return;
+ }
+ _error = msg.message;
+ return;
+ }
+ if (msg.event.conversationId !== deps.conversationId) {
+ return;
+ }
+ transcript = foldEvent(transcript, msg.event);
+ metrics = foldMetricsEvent(metrics, msg.event);
+ maybeTrim();
+ if (transcript.sealedTurnId !== null) {
+ void syncTail();
+ void syncMetrics();
+ }
+ },
- send(text: string): void {
- transcript = appendUserMessage(transcript, text);
- maybeTrim();
- const msg: ChatSendMessage = {
- type: "chat.send",
- conversationId: deps.conversationId,
- message: text,
- ...(_model !== undefined ? { model: _model } : {}),
- ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
- };
- deps.transport.send(msg);
- },
+ send(text: string): void {
+ transcript = appendUserMessage(transcript, text);
+ maybeTrim();
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: deps.conversationId,
+ message: text,
+ ...(_model !== undefined ? { model: _model } : {}),
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ };
+ deps.transport.send(msg);
+ },
- queueMessage(text: string): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- const msg: ChatQueueMessage = {
- type: "chat.queue",
- conversationId: deps.conversationId,
- text: trimmed,
- ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
- };
- deps.transport.send(msg);
- },
+ queueMessage(text: string): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ const msg: ChatQueueMessage = {
+ type: "chat.queue",
+ conversationId: deps.conversationId,
+ text: trimmed,
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ };
+ deps.transport.send(msg);
+ },
- setModel(model: string): void {
- _model = model;
- },
+ setModel(model: string): void {
+ _model = model;
+ },
- async setChatLimit(limit: number): Promise<void> {
- const prev = chatLimit;
- chatLimit = normalizeChatLimit(limit);
- if (chatLimit < prev) {
- maybeTrim();
- } else if (chatLimit > prev) {
- await refill();
- }
- },
+ async setChatLimit(limit: number): Promise<void> {
+ const prev = chatLimit;
+ chatLimit = normalizeChatLimit(limit);
+ if (chatLimit < prev) {
+ maybeTrim();
+ } else if (chatLimit > prev) {
+ await refill();
+ }
+ },
- async load(): Promise<void> {
- // Fresh load shows only the newest 75% of the limit — headroom before the
- // first trim. A warm cache is windowed locally (synchronously with its
- // apply — no render in between); a COLD cache passes the window to the
- // server instead (CR-5 `?limit=`), so a huge conversation never ships
- // whole. The post-sync window re-asserts the cap either way.
- const windowSize = initialWindowSize(chatLimit);
- const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
- transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
- }
- await syncTail(windowSize);
- transcript = windowTranscript(transcript, windowSize);
- await syncMetrics();
- },
+ async load(): Promise<void> {
+ // Fresh load shows only the newest 75% of the limit — headroom before the
+ // first trim. A warm cache is windowed locally (synchronously with its
+ // apply — no render in between); a COLD cache passes the window to the
+ // server instead (CR-5 `?limit=`), so a huge conversation never ships
+ // whole. The post-sync window re-asserts the cap either way.
+ const windowSize = initialWindowSize(chatLimit);
+ const cached = await deps.cache.load(deps.conversationId);
+ if (cached.length > 0) {
+ transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
+ }
+ await syncTail(windowSize);
+ transcript = windowTranscript(transcript, windowSize);
+ await syncMetrics();
+ },
- async showEarlier(): Promise<void> {
- if (disposed) return;
- const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
- const want = unloadCount(chatLimit);
- try {
- const earlier = await backfillOlder(oldest, want);
- transcript = restoreEarlier(transcript, earlier, want);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- }
- },
+ async showEarlier(): Promise<void> {
+ if (disposed) return;
+ const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
+ if (oldest <= 1) return;
+ const want = unloadCount(chatLimit);
+ try {
+ const earlier = await backfillOlder(oldest, want);
+ transcript = restoreEarlier(transcript, earlier, want);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ },
- resync(): void {
- if (disposed) return;
- // A turn may have sealed while we were disconnected (missed `turn-sealed`):
- // clear the now-stale spinner BEFORE re-subscribing, so a finished turn
- // doesn't spin forever. A still-running turn's replay re-asserts it.
- transcript = clearGenerating(transcript);
- void syncTail();
- void syncMetrics();
- },
+ resync(): void {
+ if (disposed) return;
+ // A turn may have sealed while we were disconnected (missed `turn-sealed`):
+ // clear the now-stale spinner BEFORE re-subscribing, so a finished turn
+ // doesn't spin forever. A still-running turn's replay re-asserts it.
+ transcript = clearGenerating(transcript);
+ void syncTail();
+ void syncMetrics();
+ },
- dispose(): void {
- disposed = true;
- },
- };
+ dispose(): void {
+ disposed = true;
+ },
+ };
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index c1d62a6..c052d1b 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -2,1548 +2,1548 @@ import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
import {
- createFakeCache,
- createFakeHistorySync,
- createFakeMetricsSync,
- createFakeTransport,
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
} from "./test-helpers";
const CONV_ID = "test-conv-1";
function makeStoredChunk(seq: number, role: "user" | "assistant" = "assistant"): StoredChunk {
- return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
+ return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
}
function deltaEvent(event: AgentEvent): import("@dispatch/transport-contract").ChatDeltaMessage {
- return { type: "chat.delta", event };
+ return { type: "chat.delta", event };
}
function errorMessage(message: string): import("@dispatch/transport-contract").ChatErrorMessage {
- return { type: "chat.error", message };
+ return { type: "chat.error", message };
}
describe("createChatStore", () => {
- it("folding a chat.delta updates messages", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
- );
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
- );
-
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("assistant");
- expect(store.messages[0]?.chunks).toHaveLength(1);
- expect(store.messages[0]?.chunks[0]?.type).toBe("text");
- expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
- "Hello world",
- );
-
- store.dispose();
- });
-
- it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Set up what the history sync will return
- historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
- );
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait for the async sync to complete
- await vi.waitFor(() => {
- expect(historySync.calls).toHaveLength(1);
- });
-
- expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
-
- // Cache should have the committed chunks
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(2);
-
- // Messages should include both provisional and committed
- expect(store.messages.length).toBeGreaterThanOrEqual(1);
-
- store.dispose();
- });
-
- it("send posts a chat.send with conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello server");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.type).toBe("chat.send");
- expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
- expect(transport.sent[0]?.message).toBe("Hello server");
- expect(transport.sent[0]).not.toHaveProperty("model");
-
- store.dispose();
- });
-
- it("send posts a chat.send with model when set", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.dispose();
- });
-
- describe("queueMessage (chat.queue — steering)", () => {
- it("posts a chat.queue with conversationId + text", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage("steer left");
-
- expect(transport.sent).toHaveLength(0); // chat.send stays empty
- expect(transport.sentQueue).toHaveLength(1);
- expect(transport.sentQueue[0]?.type).toBe("chat.queue");
- expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
- expect(transport.sentQueue[0]?.text).toBe("steer left");
-
- store.dispose();
- });
-
- it("trims whitespace before sending", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage(" padded ");
-
- expect(transport.sentQueue[0]?.text).toBe("padded");
-
- store.dispose();
- });
-
- it("does not send for empty/whitespace-only text", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage(" ");
- store.queueMessage("");
-
- expect(transport.sentQueue).toHaveLength(0);
-
- store.dispose();
- });
-
- it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage("queued steering message");
-
- expect(store.chunks).toHaveLength(0); // no transcript echo
-
- store.dispose();
- });
- });
-
- it("chat.error sets error", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.error).toBeNull();
-
- store.handleDelta(errorMessage("Something broke"));
-
- expect(store.error).toBe("Something broke");
-
- store.dispose();
- });
-
- it("load hydrates from cache then syncs the tail", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- // Pre-populate cache
- await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
-
- // History sync returns new chunks
- historySync.returnChunks = [makeStoredChunk(3, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- // Should have synced
- expect(historySync.calls).toHaveLength(1);
- expect(historySync.calls[0]?.sinceSeq).toBe(2);
-
- // Messages should include all chunks
- expect(store.messages.length).toBeGreaterThanOrEqual(2);
-
- store.dispose();
- });
-
- it("load with empty cache still syncs", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- historySync.returnChunks = [makeStoredChunk(1, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- expect(historySync.calls).toHaveLength(1);
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
-
- store.dispose();
- });
-
- it("error is cleared on successful sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // First, set an error
- store.handleDelta(errorMessage("fail"));
- expect(store.error).toBe("fail");
-
- // Now trigger a successful sync via turn-sealed
- historySync.returnChunks = [makeStoredChunk(1)];
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(store.error).toBeNull();
- });
-
- store.dispose();
- });
-
- it("dispose prevents further syncs", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.dispose();
-
- // Trigger a turn-sealed after dispose
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait a tick to let any async work settle
- await new Promise((r) => setTimeout(r, 10));
-
- // No sync should have happened
- expect(historySync.calls).toHaveLength(0);
-
- store.dispose();
- });
-
- it("overlapping syncs are guarded", async () => {
- const transport = createFakeTransport();
- const _historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- // Make the first sync slow
- let resolveFirstSync: (() => void) | undefined;
- const firstSyncPromise = new Promise<void>((resolve) => {
- resolveFirstSync = resolve;
- });
-
- let callCount = 0;
- const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
- callCount++;
- if (callCount === 1) {
- await firstSyncPromise;
- }
- return { chunks: [], latestSeq: sinceSeq };
- };
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: slowHistorySync,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Trigger first sync
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait a tick so the first sync starts
- await new Promise((r) => setTimeout(r, 0));
-
- // Trigger second sync while first is pending
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
-
- // Only one call should have been made (second was guarded)
- expect(callCount).toBe(1);
-
- // Release the first sync
- resolveFirstSync?.();
- await new Promise((r) => setTimeout(r, 10));
-
- store.dispose();
- });
-
- it("handles tool-call and tool-result chunks", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp/test.txt" },
- stepId: "t1#0" as StepId,
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "tool-result",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents",
- isError: false,
- stepId: "t1#0" as StepId,
- }),
- );
-
- expect(store.chunks).toHaveLength(2);
- expect(store.chunks[0]?.chunk.type).toBe("tool-call");
- expect(store.chunks[1]?.chunk.type).toBe("tool-result");
-
- store.dispose();
- });
-
- it("setModel changes the model used by the next send", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("First");
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.setModel("anthropic/claude-3");
- store.send("Second");
- expect(transport.sent[1]?.model).toBe("anthropic/claude-3");
-
- store.dispose();
- });
-
- it("setModel from undefined to a model", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("First");
- expect(transport.sent[0]).not.toHaveProperty("model");
-
- store.setModel("openai/gpt-4o");
- store.send("Second");
- expect(transport.sent[1]?.model).toBe("openai/gpt-4o");
-
- store.dispose();
- });
-
- it("handleDelta ignores a chat.delta for a different conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(
- deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }),
- );
- store.handleDelta(
- deltaEvent({
- type: "text-delta",
- conversationId: "other-conv",
- turnId: "t1",
- delta: "Should be ignored",
- }),
- );
-
- expect(store.messages).toHaveLength(0);
-
- store.dispose();
- });
-
- it("handleDelta ignores a chat.error for a different conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" });
-
- expect(store.error).toBeNull();
-
- store.dispose();
- });
-
- it("send optimistically shows the user message immediately", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("hi");
-
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("user");
- expect(store.messages[0]?.chunks).toHaveLength(1);
- expect(store.messages[0]?.chunks[0]?.type).toBe("text");
- expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi");
-
- store.dispose();
- });
-
- it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- historySync.returnChunks = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
- ];
-
- store.send("hi");
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("user");
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(store.messages.length).toBe(2);
- });
-
- expect(store.messages[0]?.role).toBe("user");
- expect(store.messages[1]?.role).toBe("assistant");
-
- store.dispose();
- });
-
- it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.turnMetrics).toHaveLength(0);
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "step-complete",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 200,
- genTotalMs: 800,
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- durationMs: 1200,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- const entry = store.turnMetrics[0];
- expect(entry?.turnId).toBe("t1");
- expect(entry?.steps).toHaveLength(1);
- expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
- expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
- expect(entry?.steps[0]?.genTotalMs).toBe(800);
- expect(entry?.total).not.toBeNull();
- expect(entry?.total?.usage.inputTokens).toBe(100);
- expect(entry?.total?.usage.outputTokens).toBe(50);
- expect(entry?.total?.durationMs).toBe(1200);
-
- store.dispose();
- });
-
- it("turnMetrics entry has total: null before done (progressive turn)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "step-complete",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 200,
- genTotalMs: 800,
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- const entry = store.turnMetrics[0];
- expect(entry?.turnId).toBe("t1");
- expect(entry?.steps).toHaveLength(1);
- expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
- expect(entry?.total).toBeNull();
-
- store.dispose();
- });
-
- it("metricsSync durable result overrides live by turnId", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Live fold gives some metrics
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
-
- // Durable sync returns different numbers for the same turnId
- metricsSync.returnTurns = [
- {
- turnId: "t1",
- usage: { inputTokens: 200, outputTokens: 80 },
- durationMs: 500,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 200, outputTokens: 80 },
- genTotalMs: 400,
- },
- ],
- },
- ];
-
- // Trigger metrics sync via turn-sealed
- historySync.returnChunks = [];
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- // Durable should now override live (syncMetrics is async, wait for it)
- await vi.waitFor(() => {
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
- });
-
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
-
- store.dispose();
- });
-
- it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Live fold some metrics
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
-
- // Make the metrics sync reject
- metricsSync.nextError = "metrics endpoint unavailable";
-
- historySync.returnChunks = [];
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- // Live metrics should still be intact
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
-
- // No error should have been thrown to the store
- expect(store.error).toBeNull();
-
- store.dispose();
- });
-
- it("load calls metricsSync after history sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- metricsSync.returnTurns = [
- {
- turnId: "t1",
- usage: { inputTokens: 300, outputTokens: 100 },
- durationMs: 900,
- steps: [],
- },
- ];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- expect(historySync.calls).toHaveLength(1);
- expect(metricsSync.calls).toHaveLength(1);
- expect(metricsSync.calls[0]).toBe(CONV_ID);
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
-
- store.dispose();
- });
-
- it("generating reflects the turn lifecycle (idle → running → idle)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.generating).toBe(false);
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- expect(store.generating).toBe(true);
-
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }),
- );
- expect(store.generating).toBe(true);
-
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- expect(store.generating).toBe(false);
-
- store.dispose();
- });
-
- it("generating lights up for a watcher whose turn was replayed (no send first)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // A late-joiner receives the in-flight turn replayed from turn-start.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }),
- );
- expect(store.generating).toBe(true);
- expect(transport.sent).toHaveLength(0); // it never sent — it's just watching
-
- store.dispose();
- });
-
- it("resync clears a stale generating flag and re-syncs history + metrics", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was
- // missed, so generating is stuck true.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- expect(store.generating).toBe(true);
-
- // The turn actually sealed while we were gone — history now has the chunks.
- historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
-
- store.resync();
-
- // Generating is cleared synchronously (a finished turn must not spin forever).
- expect(store.generating).toBe(false);
-
- await vi.waitFor(() => {
- expect(historySync.calls).toHaveLength(1);
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- store.dispose();
- });
-
- it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // Commit exactly 100 chunks via a sealed turn (at the limit — no trim).
- const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1));
- historySync.returnChunks = hundred;
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(100);
- });
- expect(store.hasEarlier).toBe(false);
-
- // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t2",
- toolCallId: "tc1",
- toolName: "probe",
- input: {},
- stepId: "t2#0" as StepId,
- }),
- );
-
- expect(store.chunks).toHaveLength(76);
- expect(store.chunks[0]?.seq).toBe(26);
- expect(store.hasEarlier).toBe(true);
-
- store.dispose();
- });
-
- it("chat limit: unloading is deferred while the gate is closed, then catches up", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- let atBottom = false; // reader scrolled up
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 10,
- canUnload: () => atBottom,
- });
-
- // 15 live tool-calls: over the limit, but the gate defers every trim.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- for (let i = 0; i < 15; i++) {
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: `tc${i}`,
- toolName: "probe",
- input: {},
- stepId: `t1#${i}` as StepId,
- }),
- );
- }
- expect(store.chunks).toHaveLength(15);
-
- // Reader returns to the bottom — the deferred trim now catches up.
- // With no committed chunks, it drops the oldest provisional chunks
- // (the in-flight turn) to stay within the limit.
- atBottom = true;
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc15",
- toolName: "probe",
- input: {},
- stepId: "t1#15" as StepId,
- }),
- );
- // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
- expect(store.chunks).toHaveLength(10);
-
- store.dispose();
- });
-
- it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- let atBottom = false;
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- canUnload: () => atBottom,
- });
-
- // Seal a turn committing 130 chunks while the reader is scrolled up: no trim.
- historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(130);
- });
-
- // Back at the bottom: the next fold trims whole quarters down to ≤ 100.
- atBottom = true;
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk).
- expect(store.chunks).toHaveLength(80);
- expect(store.chunks[0]?.seq).toBe(51);
-
- store.dispose();
- });
-
- it("chat limit: load windows a long cached conversation to 75% of the limit", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- // floor(100 × 0.75) = 75 newest chunks: seqs 426..500.
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(426);
- expect(store.hasEarlier).toBe(true);
- // The tail sync still used the cache's real cursor (not the window's edge).
- expect(historySync.calls[0]?.sinceSeq).toBe(500);
-
- store.dispose();
- });
-
- it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // The server holds 500 chunks; the windowed fetch returns the newest 75.
- historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`).
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
- expect(historySync.calls[0]?.window).toEqual({ limit: 75 });
-
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(426);
- // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) —
- // no local watermark was ever set.
- expect(store.hasEarlier).toBe(true);
- // Only the window was shipped + cached (the point of CR-5).
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(75);
-
- store.dispose();
- });
-
- it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]);
- historySync.returnChunks = [makeStoredChunk(3)];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- expect(historySync.calls[0]?.sinceSeq).toBe(2);
- expect(historySync.calls[0]?.window).toBeUndefined();
-
- store.dispose();
- });
-
- it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load(); // server-windowed: loaded + cached = 426..500
- expect(store.chunks[0]?.seq).toBe(426);
-
- await store.showEarlier();
-
- // Nothing below 426 was cached → fetched the missing run from the server.
- const backfill = historySync.calls[1];
- expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 });
- expect(store.chunks).toHaveLength(100);
- expect(store.chunks[0]?.seq).toBe(401);
- expect(store.hasEarlier).toBe(true);
- // The backfilled run is persisted: the NEXT page-in is cache-local.
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(100);
-
- store.dispose();
- });
-
- it("chat limit: showEarlier pages a quarter back in from the cache", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
- expect(store.chunks[0]?.seq).toBe(426);
-
- await store.showEarlier(); // +ceil(100/4) = 25 older chunks
- expect(store.chunks).toHaveLength(100);
- expect(store.chunks[0]?.seq).toBe(401);
- expect(store.hasEarlier).toBe(true);
- // The cache reached deep enough — no server backfill was needed.
- expect(historySync.calls).toHaveLength(1);
-
- store.dispose();
- });
-
- it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load(); // window 75: hidden 1..5
- expect(store.chunks).toHaveLength(75);
- expect(store.hasEarlier).toBe(true);
-
- await store.showEarlier(); // restores all 5 → nothing left below
- expect(store.chunks).toHaveLength(80);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(store.hasEarlier).toBe(false);
-
- store.dispose();
- });
-
- it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
- expect(store.chunks[0]?.seq).toBe(426);
-
- // A sealed turn triggers syncTail, whose cache.commit returns the FULL
- // merged cache (seqs 1..501) — the watermark must keep 1..425 out.
- historySync.returnChunks = [makeStoredChunk(501)];
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" }));
-
- await vi.waitFor(() => {
- expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501);
- });
- expect(store.chunks[0]?.seq).toBe(426);
- expect(store.chunks).toHaveLength(76);
-
- store.dispose();
- });
-
- it("setChatLimit: lowering the limit trims older committed chunks live", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // Load 80 committed chunks (under the limit — no trim yet).
- historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(80);
- });
-
- // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs
- // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain.
- await store.setChatLimit(10);
- expect(store.chunks).toHaveLength(8);
- expect(store.chunks[0]?.seq).toBe(73);
- expect(store.hasEarlier).toBe(true);
-
- store.dispose();
- });
-
- it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200.
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
- );
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load();
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(126);
- expect(store.hasEarlier).toBe(true);
-
- // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks
- // (seqs 51..125) from the cache. No server backfill (cache is deep enough).
- await store.setChatLimit(200);
- expect(historySync.calls).toHaveLength(1); // the load-time tail sync only
- expect(store.chunks).toHaveLength(150);
- expect(store.chunks[0]?.seq).toBe(51);
- expect(store.hasEarlier).toBe(true); // 51 > 1
-
- store.dispose();
- });
-
- it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
- historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load();
- expect(store.chunks[0]?.seq).toBe(126);
-
- // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill
- // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75).
- await store.setChatLimit(200);
- const backfill = historySync.calls[1];
- expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 });
- expect(store.chunks).toHaveLength(150);
- expect(store.chunks[0]?.seq).toBe(51);
-
- store.dispose();
- });
-
- it("setChatLimit: raising refills all available older history (down to the origin)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101).
- historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(76);
- });
- expect(store.chunks[0]?.seq).toBe(26);
- expect(store.hasEarlier).toBe(true);
-
- // Raise to 500 → window 375 → want 299 older. The cache holds only
- // seqs 1..25 below the window (no more server-side) → restore all 25 →
- // 101 loaded, reaching the origin.
- await store.setChatLimit(500);
- expect(store.chunks).toHaveLength(101);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(store.hasEarlier).toBe(false);
-
- store.dispose();
- });
-
- it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
- );
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load(); // only 50 chunks → all loaded, window starts at seq 1
- expect(store.chunks).toHaveLength(50);
- expect(store.hasEarlier).toBe(false);
- const callsAfterLoad = historySync.calls.length;
-
- await store.setChatLimit(500); // raise → refill no-ops (oldest = 1)
- expect(store.chunks).toHaveLength(50);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill
-
- store.dispose();
- });
-
- it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(50);
- });
-
- // NaN normalizes to the default (256). prev was 100 → raise → refill,
- // but the loaded window already starts at seq 1 (origin) → no-op.
- await store.setChatLimit(Number.NaN);
- expect(store.chunks).toHaveLength(50);
-
- store.dispose();
- });
-
- it("resync is a no-op after dispose", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.dispose();
- store.resync();
-
- await new Promise((r) => setTimeout(r, 10));
- expect(historySync.calls).toHaveLength(0);
- expect(metricsSync.calls).toHaveLength(0);
- });
+ it("folding a chat.delta updates messages", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
+ );
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("assistant");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
+ "Hello world",
+ );
+
+ store.dispose();
+ });
+
+ it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Set up what the history sync will return
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait for the async sync to complete
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ });
+
+ expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ // Cache should have the committed chunks
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(2);
+
+ // Messages should include both provisional and committed
+ expect(store.messages.length).toBeGreaterThanOrEqual(1);
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("Hello server");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.type).toBe("chat.send");
+ expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sent[0]?.message).toBe("Hello server");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with model when set", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ model: "openai/gpt-4",
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("Hello");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.dispose();
+ });
+
+ describe("queueMessage (chat.queue — steering)", () => {
+ it("posts a chat.queue with conversationId + text", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage("steer left");
+
+ expect(transport.sent).toHaveLength(0); // chat.send stays empty
+ expect(transport.sentQueue).toHaveLength(1);
+ expect(transport.sentQueue[0]?.type).toBe("chat.queue");
+ expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sentQueue[0]?.text).toBe("steer left");
+
+ store.dispose();
+ });
+
+ it("trims whitespace before sending", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage(" padded ");
+
+ expect(transport.sentQueue[0]?.text).toBe("padded");
+
+ store.dispose();
+ });
+
+ it("does not send for empty/whitespace-only text", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage(" ");
+ store.queueMessage("");
+
+ expect(transport.sentQueue).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage("queued steering message");
+
+ expect(store.chunks).toHaveLength(0); // no transcript echo
+
+ store.dispose();
+ });
+ });
+
+ it("chat.error sets error", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.error).toBeNull();
+
+ store.handleDelta(errorMessage("Something broke"));
+
+ expect(store.error).toBe("Something broke");
+
+ store.dispose();
+ });
+
+ it("load hydrates from cache then syncs the tail", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ // Pre-populate cache
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
+
+ // History sync returns new chunks
+ historySync.returnChunks = [makeStoredChunk(3, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ // Should have synced
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+
+ // Messages should include all chunks
+ expect(store.messages.length).toBeGreaterThanOrEqual(2);
+
+ store.dispose();
+ });
+
+ it("load with empty cache still syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ historySync.returnChunks = [makeStoredChunk(1, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ store.dispose();
+ });
+
+ it("error is cleared on successful sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // First, set an error
+ store.handleDelta(errorMessage("fail"));
+ expect(store.error).toBe("fail");
+
+ // Now trigger a successful sync via turn-sealed
+ historySync.returnChunks = [makeStoredChunk(1)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.error).toBeNull();
+ });
+
+ store.dispose();
+ });
+
+ it("dispose prevents further syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.dispose();
+
+ // Trigger a turn-sealed after dispose
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick to let any async work settle
+ await new Promise((r) => setTimeout(r, 10));
+
+ // No sync should have happened
+ expect(historySync.calls).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("overlapping syncs are guarded", async () => {
+ const transport = createFakeTransport();
+ const _historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ // Make the first sync slow
+ let resolveFirstSync: (() => void) | undefined;
+ const firstSyncPromise = new Promise<void>((resolve) => {
+ resolveFirstSync = resolve;
+ });
+
+ let callCount = 0;
+ const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
+ callCount++;
+ if (callCount === 1) {
+ await firstSyncPromise;
+ }
+ return { chunks: [], latestSeq: sinceSeq };
+ };
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: slowHistorySync,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Trigger first sync
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick so the first sync starts
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Trigger second sync while first is pending
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
+
+ // Only one call should have been made (second was guarded)
+ expect(callCount).toBe(1);
+
+ // Release the first sync
+ resolveFirstSync?.();
+ await new Promise((r) => setTimeout(r, 10));
+
+ store.dispose();
+ });
+
+ it("handles tool-call and tool-result chunks", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ stepId: "t1#0" as StepId,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(2);
+ expect(store.chunks[0]?.chunk.type).toBe("tool-call");
+ expect(store.chunks[1]?.chunk.type).toBe("tool-result");
+
+ store.dispose();
+ });
+
+ it("setModel changes the model used by the next send", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ model: "openai/gpt-4",
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("First");
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.setModel("anthropic/claude-3");
+ store.send("Second");
+ expect(transport.sent[1]?.model).toBe("anthropic/claude-3");
+
+ store.dispose();
+ });
+
+ it("setModel from undefined to a model", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("First");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.setModel("openai/gpt-4o");
+ store.send("Second");
+ expect(transport.sent[1]?.model).toBe("openai/gpt-4o");
+
+ store.dispose();
+ });
+
+ it("handleDelta ignores a chat.delta for a different conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(
+ deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "text-delta",
+ conversationId: "other-conv",
+ turnId: "t1",
+ delta: "Should be ignored",
+ }),
+ );
+
+ expect(store.messages).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("handleDelta ignores a chat.error for a different conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" });
+
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("send optimistically shows the user message immediately", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("hi");
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi");
+
+ store.dispose();
+ });
+
+ it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ historySync.returnChunks = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
+ ];
+
+ store.send("hi");
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.messages.length).toBe(2);
+ });
+
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[1]?.role).toBe("assistant");
+
+ store.dispose();
+ });
+
+ it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.turnMetrics).toHaveLength(0);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ durationMs: 1200,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
+ expect(entry?.steps[0]?.genTotalMs).toBe(800);
+ expect(entry?.total).not.toBeNull();
+ expect(entry?.total?.usage.inputTokens).toBe(100);
+ expect(entry?.total?.usage.outputTokens).toBe(50);
+ expect(entry?.total?.durationMs).toBe(1200);
+
+ store.dispose();
+ });
+
+ it("turnMetrics entry has total: null before done (progressive turn)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.total).toBeNull();
+
+ store.dispose();
+ });
+
+ it("metricsSync durable result overrides live by turnId", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold gives some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // Durable sync returns different numbers for the same turnId
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 200, outputTokens: 80 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 200, outputTokens: 80 },
+ genTotalMs: 400,
+ },
+ ],
+ },
+ ];
+
+ // Trigger metrics sync via turn-sealed
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Durable should now override live (syncMetrics is async, wait for it)
+ await vi.waitFor(() => {
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
+ });
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
+
+ store.dispose();
+ });
+
+ it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+
+ // Make the metrics sync reject
+ metricsSync.nextError = "metrics endpoint unavailable";
+
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Live metrics should still be intact
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // No error should have been thrown to the store
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("load calls metricsSync after history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 300, outputTokens: 100 },
+ durationMs: 900,
+ steps: [],
+ },
+ ];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ expect(metricsSync.calls[0]).toBe(CONV_ID);
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
+
+ store.dispose();
+ });
+
+ it("generating reflects the turn lifecycle (idle → running → idle)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.generating).toBe(false);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.generating).toBe(true);
+
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }),
+ );
+ expect(store.generating).toBe(true);
+
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ expect(store.generating).toBe(false);
+
+ store.dispose();
+ });
+
+ it("generating lights up for a watcher whose turn was replayed (no send first)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // A late-joiner receives the in-flight turn replayed from turn-start.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }),
+ );
+ expect(store.generating).toBe(true);
+ expect(transport.sent).toHaveLength(0); // it never sent — it's just watching
+
+ store.dispose();
+ });
+
+ it("resync clears a stale generating flag and re-syncs history + metrics", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was
+ // missed, so generating is stuck true.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.generating).toBe(true);
+
+ // The turn actually sealed while we were gone — history now has the chunks.
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.resync();
+
+ // Generating is cleared synchronously (a finished turn must not spin forever).
+ expect(store.generating).toBe(false);
+
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ store.dispose();
+ });
+
+ it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // Commit exactly 100 chunks via a sealed turn (at the limit — no trim).
+ const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1));
+ historySync.returnChunks = hundred;
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(100);
+ });
+ expect(store.hasEarlier).toBe(false);
+
+ // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t2",
+ toolCallId: "tc1",
+ toolName: "probe",
+ input: {},
+ stepId: "t2#0" as StepId,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(76);
+ expect(store.chunks[0]?.seq).toBe(26);
+ expect(store.hasEarlier).toBe(true);
+
+ store.dispose();
+ });
+
+ it("chat limit: unloading is deferred while the gate is closed, then catches up", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ let atBottom = false; // reader scrolled up
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 10,
+ canUnload: () => atBottom,
+ });
+
+ // 15 live tool-calls: over the limit, but the gate defers every trim.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ for (let i = 0; i < 15; i++) {
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: `tc${i}`,
+ toolName: "probe",
+ input: {},
+ stepId: `t1#${i}` as StepId,
+ }),
+ );
+ }
+ expect(store.chunks).toHaveLength(15);
+
+ // Reader returns to the bottom — the deferred trim now catches up.
+ // With no committed chunks, it drops the oldest provisional chunks
+ // (the in-flight turn) to stay within the limit.
+ atBottom = true;
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc15",
+ toolName: "probe",
+ input: {},
+ stepId: "t1#15" as StepId,
+ }),
+ );
+ // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
+ expect(store.chunks).toHaveLength(10);
+
+ store.dispose();
+ });
+
+ it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ let atBottom = false;
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ canUnload: () => atBottom,
+ });
+
+ // Seal a turn committing 130 chunks while the reader is scrolled up: no trim.
+ historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(130);
+ });
+
+ // Back at the bottom: the next fold trims whole quarters down to ≤ 100.
+ atBottom = true;
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk).
+ expect(store.chunks).toHaveLength(80);
+ expect(store.chunks[0]?.seq).toBe(51);
+
+ store.dispose();
+ });
+
+ it("chat limit: load windows a long cached conversation to 75% of the limit", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ // floor(100 × 0.75) = 75 newest chunks: seqs 426..500.
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(426);
+ expect(store.hasEarlier).toBe(true);
+ // The tail sync still used the cache's real cursor (not the window's edge).
+ expect(historySync.calls[0]?.sinceSeq).toBe(500);
+
+ store.dispose();
+ });
+
+ it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // The server holds 500 chunks; the windowed fetch returns the newest 75.
+ historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`).
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+ expect(historySync.calls[0]?.window).toEqual({ limit: 75 });
+
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(426);
+ // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) —
+ // no local watermark was ever set.
+ expect(store.hasEarlier).toBe(true);
+ // Only the window was shipped + cached (the point of CR-5).
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(75);
+
+ store.dispose();
+ });
+
+ it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]);
+ historySync.returnChunks = [makeStoredChunk(3)];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+ expect(historySync.calls[0]?.window).toBeUndefined();
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load(); // server-windowed: loaded + cached = 426..500
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ await store.showEarlier();
+
+ // Nothing below 426 was cached → fetched the missing run from the server.
+ const backfill = historySync.calls[1];
+ expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 });
+ expect(store.chunks).toHaveLength(100);
+ expect(store.chunks[0]?.seq).toBe(401);
+ expect(store.hasEarlier).toBe(true);
+ // The backfilled run is persisted: the NEXT page-in is cache-local.
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(100);
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier pages a quarter back in from the cache", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ await store.showEarlier(); // +ceil(100/4) = 25 older chunks
+ expect(store.chunks).toHaveLength(100);
+ expect(store.chunks[0]?.seq).toBe(401);
+ expect(store.hasEarlier).toBe(true);
+ // The cache reached deep enough — no server backfill was needed.
+ expect(historySync.calls).toHaveLength(1);
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load(); // window 75: hidden 1..5
+ expect(store.chunks).toHaveLength(75);
+ expect(store.hasEarlier).toBe(true);
+
+ await store.showEarlier(); // restores all 5 → nothing left below
+ expect(store.chunks).toHaveLength(80);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(store.hasEarlier).toBe(false);
+
+ store.dispose();
+ });
+
+ it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ // A sealed turn triggers syncTail, whose cache.commit returns the FULL
+ // merged cache (seqs 1..501) — the watermark must keep 1..425 out.
+ historySync.returnChunks = [makeStoredChunk(501)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" }));
+
+ await vi.waitFor(() => {
+ expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501);
+ });
+ expect(store.chunks[0]?.seq).toBe(426);
+ expect(store.chunks).toHaveLength(76);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: lowering the limit trims older committed chunks live", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // Load 80 committed chunks (under the limit — no trim yet).
+ historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(80);
+ });
+
+ // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs
+ // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain.
+ await store.setChatLimit(10);
+ expect(store.chunks).toHaveLength(8);
+ expect(store.chunks[0]?.seq).toBe(73);
+ expect(store.hasEarlier).toBe(true);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200.
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load();
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(126);
+ expect(store.hasEarlier).toBe(true);
+
+ // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks
+ // (seqs 51..125) from the cache. No server backfill (cache is deep enough).
+ await store.setChatLimit(200);
+ expect(historySync.calls).toHaveLength(1); // the load-time tail sync only
+ expect(store.chunks).toHaveLength(150);
+ expect(store.chunks[0]?.seq).toBe(51);
+ expect(store.hasEarlier).toBe(true); // 51 > 1
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
+ historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(126);
+
+ // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill
+ // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75).
+ await store.setChatLimit(200);
+ const backfill = historySync.calls[1];
+ expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 });
+ expect(store.chunks).toHaveLength(150);
+ expect(store.chunks[0]?.seq).toBe(51);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising refills all available older history (down to the origin)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101).
+ historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(76);
+ });
+ expect(store.chunks[0]?.seq).toBe(26);
+ expect(store.hasEarlier).toBe(true);
+
+ // Raise to 500 → window 375 → want 299 older. The cache holds only
+ // seqs 1..25 below the window (no more server-side) → restore all 25 →
+ // 101 loaded, reaching the origin.
+ await store.setChatLimit(500);
+ expect(store.chunks).toHaveLength(101);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(store.hasEarlier).toBe(false);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load(); // only 50 chunks → all loaded, window starts at seq 1
+ expect(store.chunks).toHaveLength(50);
+ expect(store.hasEarlier).toBe(false);
+ const callsAfterLoad = historySync.calls.length;
+
+ await store.setChatLimit(500); // raise → refill no-ops (oldest = 1)
+ expect(store.chunks).toHaveLength(50);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill
+
+ store.dispose();
+ });
+
+ it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(50);
+ });
+
+ // NaN normalizes to the default (256). prev was 100 → raise → refill,
+ // but the loaded window already starts at seq 1 (origin) → no-op.
+ await store.setChatLimit(Number.NaN);
+ expect(store.chunks).toHaveLength(50);
+
+ store.dispose();
+ });
+
+ it("resync is a no-op after dispose", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.dispose();
+ store.resync();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(historySync.calls).toHaveLength(0);
+ expect(metricsSync.calls).toHaveLength(0);
+ });
});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index 100449f..26c5590 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -4,139 +4,139 @@ import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export interface FakeTransport {
- /** All `chat.send` messages sent through the fake transport. */
- readonly sent: ChatSendMessage[];
- /** All `chat.queue` messages sent through the fake transport. */
- readonly sentQueue: ChatQueueMessage[];
- readonly impl: ChatTransport;
+ /** All `chat.send` messages sent through the fake transport. */
+ readonly sent: ChatSendMessage[];
+ /** All `chat.queue` messages sent through the fake transport. */
+ readonly sentQueue: ChatQueueMessage[];
+ readonly impl: ChatTransport;
}
export function createFakeTransport(): FakeTransport {
- const sent: ChatSendMessage[] = [];
- const sentQueue: ChatQueueMessage[] = [];
- return {
- sent,
- sentQueue,
- impl: {
- send(msg) {
- if (msg.type === "chat.queue") {
- sentQueue.push(msg);
- } else {
- sent.push(msg);
- }
- },
- },
- };
+ const sent: ChatSendMessage[] = [];
+ const sentQueue: ChatQueueMessage[] = [];
+ return {
+ sent,
+ sentQueue,
+ impl: {
+ send(msg) {
+ if (msg.type === "chat.queue") {
+ sentQueue.push(msg);
+ } else {
+ sent.push(msg);
+ }
+ },
+ },
+ };
}
export interface FakeHistorySync {
- readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
- /** Set the chunks to return on the next call. */
- returnChunks: readonly StoredChunk[];
- readonly impl: HistorySync;
+ readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
+ /** Set the chunks to return on the next call. */
+ returnChunks: readonly StoredChunk[];
+ readonly impl: HistorySync;
}
export function createFakeHistorySync(): FakeHistorySync {
- const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
- let returnChunks: readonly StoredChunk[] = [];
- return {
- calls,
- get returnChunks() {
- return returnChunks;
- },
- set returnChunks(v: readonly StoredChunk[]) {
- returnChunks = v;
- },
- impl: async (conversationId, sinceSeq, window) => {
- calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
- // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
- // so store tests exercise the real windowed flows. `sinceSeq` filtering is
- // deliberately NOT applied — tests set `returnChunks` to the slice they
- // mean the server to hold past the cursor.
- let chunks = returnChunks;
- const before = window?.beforeSeq;
- if (before !== undefined) {
- chunks = chunks.filter((c) => c.seq < before);
- }
- if (window?.limit !== undefined && chunks.length > window.limit) {
- chunks = chunks.slice(-window.limit);
- }
- const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
- return { chunks, latestSeq };
- },
- };
+ const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
+ let returnChunks: readonly StoredChunk[] = [];
+ return {
+ calls,
+ get returnChunks() {
+ return returnChunks;
+ },
+ set returnChunks(v: readonly StoredChunk[]) {
+ returnChunks = v;
+ },
+ impl: async (conversationId, sinceSeq, window) => {
+ calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
+ // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
+ // so store tests exercise the real windowed flows. `sinceSeq` filtering is
+ // deliberately NOT applied — tests set `returnChunks` to the slice they
+ // mean the server to hold past the cursor.
+ let chunks = returnChunks;
+ const before = window?.beforeSeq;
+ if (before !== undefined) {
+ chunks = chunks.filter((c) => c.seq < before);
+ }
+ if (window?.limit !== undefined && chunks.length > window.limit) {
+ chunks = chunks.slice(-window.limit);
+ }
+ const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
+ return { chunks, latestSeq };
+ },
+ };
}
export interface FakeMetricsSync {
- readonly calls: string[];
- returnTurns: import("@dispatch/wire").TurnMetrics[];
- /** If set, the next call will reject with this error. */
- nextError: string | undefined;
- readonly impl: MetricsSync;
+ readonly calls: string[];
+ returnTurns: import("@dispatch/wire").TurnMetrics[];
+ /** If set, the next call will reject with this error. */
+ nextError: string | undefined;
+ readonly impl: MetricsSync;
}
export function createFakeMetricsSync(): FakeMetricsSync {
- const calls: string[] = [];
- let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
- let nextError: string | undefined;
- return {
- calls,
- get returnTurns() {
- return returnTurns;
- },
- set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
- returnTurns = v;
- },
- get nextError() {
- return nextError;
- },
- set nextError(v: string | undefined) {
- nextError = v;
- },
- impl: async (conversationId) => {
- calls.push(conversationId);
- if (nextError !== undefined) {
- const err = nextError;
- nextError = undefined;
- throw new Error(err);
- }
- return { turns: returnTurns };
- },
- };
+ const calls: string[] = [];
+ let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
+ let nextError: string | undefined;
+ return {
+ calls,
+ get returnTurns() {
+ return returnTurns;
+ },
+ set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
+ returnTurns = v;
+ },
+ get nextError() {
+ return nextError;
+ },
+ set nextError(v: string | undefined) {
+ nextError = v;
+ },
+ impl: async (conversationId) => {
+ calls.push(conversationId);
+ if (nextError !== undefined) {
+ const err = nextError;
+ nextError = undefined;
+ throw new Error(err);
+ }
+ return { turns: returnTurns };
+ },
+ };
}
export interface FakeCache {
- readonly store: Map<string, StoredChunk[]>;
- readonly impl: ConversationCache;
+ readonly store: Map<string, StoredChunk[]>;
+ readonly impl: ConversationCache;
}
export function createFakeCache(): FakeCache {
- const store = new Map<string, StoredChunk[]>();
- return {
- store,
- impl: {
- async load(conversationId) {
- return store.get(conversationId) ?? [];
- },
- async commit(conversationId, incoming) {
- const existing = store.get(conversationId) ?? [];
- const seen = new Set(existing.map((c) => c.seq));
- const toAppend = incoming.filter((c) => !seen.has(c.seq));
- const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
- store.set(conversationId, merged);
- return merged;
- },
- async sinceSeq(conversationId) {
- const chunks = store.get(conversationId) ?? [];
- if (chunks.length === 0) return 0;
- return Math.max(...chunks.map((c) => c.seq));
- },
- async evictIfOverBudget() {
- return [];
- },
- async delete(conversationId) {
- store.delete(conversationId);
- },
- },
- };
+ const store = new Map<string, StoredChunk[]>();
+ return {
+ store,
+ impl: {
+ async load(conversationId) {
+ return store.get(conversationId) ?? [];
+ },
+ async commit(conversationId, incoming) {
+ const existing = store.get(conversationId) ?? [];
+ const seen = new Set(existing.map((c) => c.seq));
+ const toAppend = incoming.filter((c) => !seen.has(c.seq));
+ const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
+ store.set(conversationId, merged);
+ return merged;
+ },
+ async sinceSeq(conversationId) {
+ const chunks = store.get(conversationId) ?? [];
+ if (chunks.length === 0) return 0;
+ return Math.max(...chunks.map((c) => c.seq));
+ },
+ async evictIfOverBudget() {
+ return [];
+ },
+ async delete(conversationId) {
+ store.delete(conversationId);
+ },
+ },
+ };
}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index b8b3193..a2fd944 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -10,797 +10,797 @@ import ModelSelector from "./ui/ModelSelector.svelte";
import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte";
describe("ChatView", () => {
- it("renders a message's text chunk", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "text", text: "Hello world" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Hello world")).toBeInTheDocument();
- });
-
- it("renders multiple chunks", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Hi there")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- });
-
- it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
- const chunks: RenderedChunk[] = [
- { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
- ];
-
- let resolveEarlier: (() => void) | undefined;
- const onShowEarlier = vi.fn(
- () =>
- new Promise<void>((resolve) => {
- resolveEarlier = resolve;
- }),
- );
-
- render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } });
-
- const button = screen.getByRole("button", { name: /show earlier messages/i });
- const user = userEvent.setup();
- await user.click(button);
-
- expect(onShowEarlier).toHaveBeenCalledTimes(1);
- // While the page-in is awaited the button is disabled (no double-fire).
- expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled();
-
- resolveEarlier?.();
- await vi.waitFor(() => {
- expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled();
- });
- });
-
- it("hides the show-earlier button when nothing is unloaded", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false },
- ];
-
- render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } });
-
- expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
- });
-
- it("renders tool-call chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp/test.txt" },
- },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("read_file")).toBeInTheDocument();
- const pre = screen.getByText((content, element) => {
- return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
- });
- expect(pre).toBeInTheDocument();
- });
-
- it("renders tool-result chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents here",
- isError: false,
- },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("file contents here")).toBeInTheDocument();
- });
-
- it("renders error chunks with alert role", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "error", message: "Something failed" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- const alert = screen.getByRole("alert");
- expect(alert).toHaveTextContent("Something failed");
- });
-
- it("renders error chunks with code", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Rate limited")).toBeInTheDocument();
- expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
- });
-
- it("renders system chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "system",
- chunk: { type: "system", text: "System context loaded" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("System context loaded")).toBeInTheDocument();
- });
-
- it("renders provisional (in-flight) chunks without any dimming", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "text", text: "Streaming..." },
- provisional: true,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- // In-flight chunks render at full opacity (no faded "disabled" look).
- const wrapper = screen.getByText("Streaming...").closest("div");
- expect(wrapper).not.toHaveClass("opacity-50");
- });
-
- it("renders empty transcript", () => {
- render(ChatView, { props: { chunks: [] } });
-
- const log = screen.getByRole("log");
- expect(log).toBeInTheDocument();
- expect(log.children).toHaveLength(0);
- });
-
- it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "a",
- toolName: "read_file",
- input: { path: "/a" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 2,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "b",
- toolName: "list_dir",
- input: { path: "/b" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 3,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "a",
- toolName: "read_file",
- content: "contents-of-a",
- isError: false,
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks } });
-
- // Batched calls render as collapsible cards (one per call), not a list.
- const collapses = container.querySelectorAll(".collapse");
- expect(collapses).toHaveLength(2);
-
- // Both call names + the available result are shown; the result is absorbed
- // (no standalone tool-result card).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
- expect(screen.getByText("contents-of-a")).toBeInTheDocument();
- });
-
- it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think..." },
- provisional: true,
- streaming: true,
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks } });
-
- const collapse = container.querySelector(".collapse");
- expect(collapse).not.toBeNull();
- expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
- expect(collapse).not.toHaveClass("collapse-plus");
- // Visible bubble, like tool cards.
- expect(collapse).toHaveClass("bg-base-200");
- expect(collapse).toHaveClass("rounded-box");
- expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
- });
-
- it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => {
- const streaming: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "hmm" },
- provisional: true,
- streaming: true,
- },
- ];
-
- const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
-
- // Streaming: "Thinking" + loading dots.
- expect(screen.getByText("Thinking")).toBeInTheDocument();
- expect(screen.queryByText("Thoughts")).toBeNull();
- expect(container.querySelector(".loading")).not.toBeNull();
-
- // Open it.
- const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" });
- await userEvent.click(checkbox);
- expect(checkbox).toBeChecked();
-
- // Transition generating → completed/committed (seq assigned, no longer streaming).
- await rerender({
- chunks: [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "thinking", text: "hmm, all done" },
- provisional: false,
- },
- ],
- });
-
- // Completed: "Thoughts", no dots — and the open state survived the transition.
- expect(screen.getByText("Thoughts")).toBeInTheDocument();
- expect(screen.queryByText("Thinking")).toBeNull();
- expect(container.querySelector(".loading")).toBeNull();
- expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
- expect(container).toHaveTextContent("hmm, all done");
- });
-
- it("renders step and turn metrics as separate rows", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 100, outputTokens: 50 },
- durationMs: 1200,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- expect(screen.getByText("Hi")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
- expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
- expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
- });
-
- it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [],
- total: {
- turnId: "t1",
- usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 },
- steps: [],
- },
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks, turnMetrics } });
-
- expect(screen.getByText("Last turn:")).toBeInTheDocument();
- expect(screen.getByText("Chat Total:")).toBeInTheDocument();
- // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge
- const badges = container.querySelectorAll(".badge");
- expect(badges).toHaveLength(2);
- for (const b of badges) {
- expect(b.textContent).toBe("93%");
- expect(b.classList.contains("badge-success")).toBe(true);
- }
- });
-
- it("renders step-metrics inline after tool group", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "bash",
- input: { command: "ls" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 3,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "bash",
- content: "file.txt",
- isError: false,
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 4,
- role: "assistant",
- chunk: { type: "text", text: "Done!" },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 80, outputTokens: 20 },
- genTotalMs: 300,
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 80, outputTokens: 20 },
- durationMs: 500,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 80, outputTokens: 20 },
- genTotalMs: 300,
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Both step-metrics and turn-metrics render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
-
- // They are in separate elements (different rows)
- const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
- const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
- expect(stepEl).not.toBe(turnEl);
- });
-
- it("renders no metrics bubble when turnMetrics is empty", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics: [] } });
-
- expect(screen.getByText("Hi")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- expect(screen.queryByText(/step 1/)).toBeNull();
- expect(screen.queryByText(/^turn/)).toBeNull();
- });
-
- it("omits null view values from metrics bubbles", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Response" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 10, outputTokens: 5 },
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 10, outputTokens: 5 },
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 10, outputTokens: 5 },
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Step metrics rendered
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
- // Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
- // No "null" or "undefined" in the DOM
- expect(screen.queryByText("null")).toBeNull();
- expect(screen.queryByText("undefined")).toBeNull();
- });
-
- it("renders step text but no turn total for a progressive turn (total: null)", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- total: null,
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
-
- // Turn total should NOT render (total is null — turn still in progress)
- expect(screen.queryByText(/^turn/)).toBeNull();
- });
+ it("renders a message's text chunk", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello world" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
+ });
+
+ it("renders multiple chunks", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hi there")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ });
+
+ it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
+ ];
+
+ let resolveEarlier: (() => void) | undefined;
+ const onShowEarlier = vi.fn(
+ () =>
+ new Promise<void>((resolve) => {
+ resolveEarlier = resolve;
+ }),
+ );
+
+ render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } });
+
+ const button = screen.getByRole("button", { name: /show earlier messages/i });
+ const user = userEvent.setup();
+ await user.click(button);
+
+ expect(onShowEarlier).toHaveBeenCalledTimes(1);
+ // While the page-in is awaited the button is disabled (no double-fire).
+ expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled();
+
+ resolveEarlier?.();
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled();
+ });
+ });
+
+ it("hides the show-earlier button when nothing is unloaded", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false },
+ ];
+
+ render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } });
+
+ expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
+ });
+
+ it("renders tool-call chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ const pre = screen.getByText((content, element) => {
+ return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
+ });
+ expect(pre).toBeInTheDocument();
+ });
+
+ it("renders tool-result chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents here",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("file contents here")).toBeInTheDocument();
+ });
+
+ it("renders error chunks with alert role", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Something failed" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Something failed");
+ });
+
+ it("renders error chunks with code", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Rate limited")).toBeInTheDocument();
+ expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
+ });
+
+ it("renders system chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "system",
+ chunk: { type: "system", text: "System context loaded" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("System context loaded")).toBeInTheDocument();
+ });
+
+ it("renders provisional (in-flight) chunks without any dimming", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "text", text: "Streaming..." },
+ provisional: true,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ // In-flight chunks render at full opacity (no faded "disabled" look).
+ const wrapper = screen.getByText("Streaming...").closest("div");
+ expect(wrapper).not.toHaveClass("opacity-50");
+ });
+
+ it("renders empty transcript", () => {
+ render(ChatView, { props: { chunks: [] } });
+
+ const log = screen.getByRole("log");
+ expect(log).toBeInTheDocument();
+ expect(log.children).toHaveLength(0);
+ });
+
+ it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ // Batched calls render as collapsible cards (one per call), not a list.
+ const collapses = container.querySelectorAll(".collapse");
+ expect(collapses).toHaveLength(2);
+
+ // Both call names + the available result are shown; the result is absorbed
+ // (no standalone tool-result card).
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ });
+
+ it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "thinking", text: "Let me think..." },
+ provisional: true,
+ streaming: true,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ const collapse = container.querySelector(".collapse");
+ expect(collapse).not.toBeNull();
+ expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
+ expect(collapse).not.toHaveClass("collapse-plus");
+ // Visible bubble, like tool cards.
+ expect(collapse).toHaveClass("bg-base-200");
+ expect(collapse).toHaveClass("rounded-box");
+ expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
+ });
+
+ it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => {
+ const streaming: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "thinking", text: "hmm" },
+ provisional: true,
+ streaming: true,
+ },
+ ];
+
+ const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
+
+ // Streaming: "Thinking" + loading dots.
+ expect(screen.getByText("Thinking")).toBeInTheDocument();
+ expect(screen.queryByText("Thoughts")).toBeNull();
+ expect(container.querySelector(".loading")).not.toBeNull();
+
+ // Open it.
+ const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" });
+ await userEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+
+ // Transition generating → completed/committed (seq assigned, no longer streaming).
+ await rerender({
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "thinking", text: "hmm, all done" },
+ provisional: false,
+ },
+ ],
+ });
+
+ // Completed: "Thoughts", no dots — and the open state survived the transition.
+ expect(screen.getByText("Thoughts")).toBeInTheDocument();
+ expect(screen.queryByText("Thinking")).toBeNull();
+ expect(container.querySelector(".loading")).toBeNull();
+ expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
+ expect(container).toHaveTextContent("hmm, all done");
+ });
+
+ it("renders step and turn metrics as separate rows", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ durationMs: 1200,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
+ expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
+ });
+
+ it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 },
+ steps: [],
+ },
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Last turn:")).toBeInTheDocument();
+ expect(screen.getByText("Chat Total:")).toBeInTheDocument();
+ // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge
+ const badges = container.querySelectorAll(".badge");
+ expect(badges).toHaveLength(2);
+ for (const b of badges) {
+ expect(b.textContent).toBe("93%");
+ expect(b.classList.contains("badge-success")).toBe(true);
+ }
+ });
+
+ it("renders step-metrics inline after tool group", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "bash",
+ input: { command: "ls" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "bash",
+ content: "file.txt",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: { type: "text", text: "Done!" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 80, outputTokens: 20 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Both step-metrics and turn-metrics render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+
+ // They are in separate elements (different rows)
+ const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
+ expect(stepEl).not.toBe(turnEl);
+ });
+
+ it("renders no metrics bubble when turnMetrics is empty", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics: [] } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.queryByText(/step 1/)).toBeNull();
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
+
+ it("omits null view values from metrics bubbles", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Response" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics rendered
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ // Turn metrics rendered
+ expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ // No "null" or "undefined" in the DOM
+ expect(screen.queryByText("null")).toBeNull();
+ expect(screen.queryByText("undefined")).toBeNull();
+ });
+
+ it("renders step text but no turn total for a progressive turn (total: null)", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: null,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics should render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+
+ // Turn total should NOT render (total is null — turn still in progress)
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
});
describe("Composer", () => {
- it("calls onSend with the typed text and clears", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("calls onSend with the typed text and clears", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Hello world");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Hello world");
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
- expect(onSend).toHaveBeenCalledTimes(1);
- expect(onSend).toHaveBeenCalledWith("Hello world");
- expect(textarea).toHaveValue("");
- });
+ expect(onSend).toHaveBeenCalledTimes(1);
+ expect(onSend).toHaveBeenCalledWith("Hello world");
+ expect(textarea).toHaveValue("");
+ });
- it("does not call onSend with empty text", async () => {
- const onSend = vi.fn();
- const _user = userEvent.setup();
+ it("does not call onSend with empty text", async () => {
+ const onSend = vi.fn();
+ const _user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const sendButton = screen.getByRole("button", { name: "Send" });
- expect(sendButton).toBeDisabled();
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton).toBeDisabled();
- expect(onSend).not.toHaveBeenCalled();
- });
+ expect(onSend).not.toHaveBeenCalled();
+ });
- it("trims whitespace before sending", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("trims whitespace before sending", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, " hello ");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, " hello ");
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
- expect(onSend).toHaveBeenCalledWith("hello");
- });
+ expect(onSend).toHaveBeenCalledWith("hello");
+ });
- it("sends on Enter key (without Shift)", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("sends on Enter key (without Shift)", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Test message{Enter}");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Test message{Enter}");
- expect(onSend).toHaveBeenCalledWith("Test message");
- });
+ expect(onSend).toHaveBeenCalledWith("Test message");
+ });
- it("does not send on Shift+Enter", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("does not send on Shift+Enter", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
- expect(onSend).not.toHaveBeenCalled();
- });
+ expect(onSend).not.toHaveBeenCalled();
+ });
});
describe("ModelSelector", () => {
- const optionValues = (el: HTMLElement): string[] =>
- within(el)
- .getAllByRole("option")
- .map((o) => (o as HTMLOptionElement).value);
-
- it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
- const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
- render(ModelSelector, {
- props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
- });
-
- const keySelect = screen.getByRole("combobox", { name: "Key selector" });
- const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
- expect(keySelect).toHaveValue("anthropic");
- expect(modelSelect).toHaveValue("claude-3");
-
- expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
- // only the models under the selected key
- expect(optionValues(modelSelect)).toEqual(["claude-3"]);
- });
-
- it("selecting a key switches to the first model under it", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4o", onSelect },
- });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
- });
-
- it("selecting a model keeps the current key", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "openai/gpt-4o"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4", onSelect },
- });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
- });
+ const optionValues = (el: HTMLElement): string[] =>
+ within(el)
+ .getAllByRole("option")
+ .map((o) => (o as HTMLOptionElement).value);
+
+ it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
+ render(ModelSelector, {
+ props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(keySelect).toHaveValue("anthropic");
+ expect(modelSelect).toHaveValue("claude-3");
+
+ expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // only the models under the selected key
+ expect(optionValues(modelSelect)).toEqual(["claude-3"]);
+ });
+
+ it("selecting a key switches to the first model under it", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4o", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
+ });
+
+ it("selecting a model keeps the current key", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
+ });
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
-
- const select = screen.getByRole("combobox", { name: "Reasoning effort" });
- expect(select).toHaveValue("high");
- expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
- // All five ladder levels are offered.
- expect(within(select).getAllByRole("option")).toHaveLength(5);
- });
-
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
-
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
- });
-
- it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
- ok: true as const,
- reasoningEffort: level,
- }));
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- expect(save).toHaveBeenCalledTimes(1);
- expect(save).toHaveBeenCalledWith("max");
- await vi.waitFor(() => {
- expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
- });
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
- });
-
- it("a failed save shows the error and reverts to the persisted value", async () => {
- const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- await vi.waitFor(() => {
- expect(screen.getByText("nope")).toBeInTheDocument();
- });
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low");
- });
-
- it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
- const save = vi.fn(
- () =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
- resolveSave = resolve;
- }),
- );
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
-
- resolveSave?.({ ok: true, reasoningEffort: "max" });
- await vi.waitFor(() => {
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
- });
- });
+ it("renders null (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+
+ const select = screen.getByRole("combobox", { name: "Reasoning effort" });
+ expect(select).toHaveValue("high");
+ expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
+ // All five ladder levels are offered.
+ expect(within(select).getAllByRole("option")).toHaveLength(5);
+ });
+
+ it("renders a persisted level as selected", () => {
+ render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
+ });
+
+ it("selecting a level saves it via the injected port and confirms", async () => {
+ const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ reasoningEffort: level,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: null, save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledWith("max");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
+ });
+
+ it("a failed save shows the error and reverts to the persisted value", async () => {
+ const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low");
+ });
+
+ it("disables the select while a save is in flight (no double-fire)", async () => {
+ let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ const save = vi.fn(
+ () =>
+ new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ resolveSave = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: null, save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
+
+ resolveSave?.({ ok: true, reasoningEffort: "max" });
+ await vi.waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
+ });
+ });
});
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index f2899c7..e72f639 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,294 +1,347 @@
<script lang="ts">
- import type { TurnProviderRetryEvent } from "@dispatch/wire";
- import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
- import {
- interleaveTurnMetrics,
- viewCacheRate,
- viewExpectedCache,
- viewStepMetrics,
- viewTurnMetrics,
- type TurnMetricsEntry,
- } from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import type { TurnProviderRetryEvent } from "@dispatch/wire";
+ import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
+ import {
+ interleaveTurnMetrics,
+ viewCacheRate,
+ viewExpectedCache,
+ viewStepMetrics,
+ viewTurnMetrics,
+ type TurnMetricsEntry,
+ } from "../../../core/metrics";
+ import { Markdown } from "../../markdown";
- const badgeClass = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- } as const;
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
- let {
- chunks,
- turnMetrics = [],
- hasEarlier = false,
- onShowEarlier,
- thinkingKeyBase = 0,
- providerRetry = null,
- }: {
- chunks: readonly RenderedChunk[];
- turnMetrics?: readonly TurnMetricsEntry[];
- /** Earlier history is unloaded (chat limit) and can be paged back in. */
- hasEarlier?: boolean;
- /** Page earlier history back in; the caller owns scroll-position preservation. */
- onShowEarlier?: () => Promise<void>;
- /**
- * Ordinal base for thinking-collapse keys: the count of thinking chunks
- * unloaded by the chat limit, so the remaining ordinals don't shift (and
- * swap collapse state) when a trim removes older thinking blocks.
- */
- thinkingKeyBase?: number;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when
- * no retry is pending → renders the transient yellow "retrying…" banner.
- * Never persisted (never part of the message history); coalesces to the
- * newest attempt + delay, and is cleared when content resumes / turn ends.
- */
- providerRetry?: TurnProviderRetryEvent | null;
- } = $props();
+ let {
+ chunks,
+ turnMetrics = [],
+ hasEarlier = false,
+ onShowEarlier,
+ thinkingKeyBase = 0,
+ providerRetry = null,
+ }: {
+ chunks: readonly RenderedChunk[];
+ turnMetrics?: readonly TurnMetricsEntry[];
+ /** Earlier history is unloaded (chat limit) and can be paged back in. */
+ hasEarlier?: boolean;
+ /** Page earlier history back in; the caller owns scroll-position preservation. */
+ onShowEarlier?: () => Promise<void>;
+ /**
+ * Ordinal base for thinking-collapse keys: the count of thinking chunks
+ * unloaded by the chat limit, so the remaining ordinals don't shift (and
+ * swap collapse state) when a trim removes older thinking blocks.
+ */
+ thinkingKeyBase?: number;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when
+ * no retry is pending → renders the transient yellow "retrying…" banner.
+ * Never persisted (never part of the message history); coalesces to the
+ * newest attempt + delay, and is cleared when content resumes / turn ends.
+ */
+ providerRetry?: TurnProviderRetryEvent | null;
+ } = $props();
- // True while a show-earlier page-in is awaited (disables the button).
- let loadingEarlier = $state(false);
+ // True while a show-earlier page-in is awaited (disables the button).
+ let loadingEarlier = $state(false);
- async function showEarlier() {
- if (!onShowEarlier || loadingEarlier) return;
- loadingEarlier = true;
- try {
- await onShowEarlier();
- } finally {
- loadingEarlier = false;
- }
- }
+ async function showEarlier() {
+ if (!onShowEarlier || loadingEarlier) return;
+ loadingEarlier = true;
+ try {
+ await onShowEarlier();
+ } finally {
+ loadingEarlier = false;
+ }
+ }
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks));
- const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
- // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
- // survives the provisional→committed (seq null → seq N) transition, so the
- // collapse's open/close state is NOT lost when a turn seals. The ordinal
- // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing
- // older thinking blocks. (App isolates these keys per conversation via {#key}.)
- const keyedRows = $derived.by(() => {
- let thinking = thinkingKeyBase;
- return rows.map((row, i) => {
- if (row.kind === "step-metrics") {
- return { row, key: `s${row.step.stepId}` };
- }
- if (row.kind === "turn-metrics") {
- return { row, key: `m${row.turn.turnId}` };
- }
- const group = row.group;
- let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
- } else if (group.chunk.chunk.type === "thinking") {
- key = `think${thinking++}`;
- } else if (group.chunk.seq != null) {
- key = `c${group.chunk.seq}`;
- } else {
- key = `p${i}`;
- }
- return { row, key };
- });
- });
+ // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
+ // survives the provisional→committed (seq null → seq N) transition, so the
+ // collapse's open/close state is NOT lost when a turn seals. The ordinal
+ // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing
+ // older thinking blocks. (App isolates these keys per conversation via {#key}.)
+ const keyedRows = $derived.by(() => {
+ let thinking = thinkingKeyBase;
+ return rows.map((row, i) => {
+ if (row.kind === "step-metrics") {
+ return { row, key: `s${row.step.stepId}` };
+ }
+ if (row.kind === "turn-metrics") {
+ return { row, key: `m${row.turn.turnId}` };
+ }
+ const group = row.group;
+ let key: string;
+ if (group.kind === "tool-batch") {
+ key = `b${group.stepId}`;
+ } else if (group.chunk.chunk.type === "thinking") {
+ key = `think${thinking++}`;
+ } else if (group.chunk.seq != null) {
+ key = `c${group.chunk.seq}`;
+ } else {
+ key = `p${i}`;
+ }
+ return { row, key };
+ });
+ });
</script>
{#snippet chunkRow(rendered: RenderedChunk)}
- {#if rendered.role === "user"}
- <!-- User: a speech bubble, left-aligned -->
- <div class="chat chat-start">
- <div class="chat-bubble chat-bubble-primary">
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {/if}
- </div>
- </div>
- {:else if rendered.chunk.type === "thinking"}
- <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
- (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
- while generating, then "Thoughts" with no dots once complete. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle thoughts" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
- {#if rendered.streaming}
- <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
- </div>
- </div>
- </div>
- </div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "tool-call"}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{rendered.chunk.toolName}</span>
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- </div>
- {:else}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool result" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" class:text-error={rendered.chunk.isError}>
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{rendered.chunk.toolName}</span>
- {#if rendered.chunk.isError}
- <span class="badge badge-error badge-xs">error</span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
- </div>
- </div>
- {/if}
- </div>
- </div>
- {:else}
- <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
- chat-start grid as the user bubble, so it inherits identical left spacing. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "text"}
- <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
- {:else if rendered.chunk.type === "error"}
- <div class="text-error" role="alert">
- {rendered.chunk.message}
- {#if rendered.chunk.code}
- <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
- {/if}
- </div>
- {:else if rendered.chunk.type === "system"}
- <div class="text-sm opacity-70">{rendered.chunk.text}</div>
- {/if}
- </div>
- </div>
- {/if}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary">
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {/if}
+ </div>
+ </div>
+ {:else if rendered.chunk.type === "thinking"}
+ <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
+ (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
+ while generating, then "Thoughts" with no dots once complete. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle thoughts" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
+ {#if rendered.streaming}
+ <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
+ <!-- Single tool call/result: a collapsible card (collapsed by default,
+ like thinking). Title shows the tool name; content shows the
+ input/output. Same chat-start grid shim as the thinking block. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ {#if rendered.chunk.type === "tool-call"}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool call" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{rendered.chunk.toolName}</span>
+ </div>
+ <div class="collapse-content">
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(
+ rendered.chunk.input,
+ null,
+ 2,
+ )}</pre>
+ </div>
+ </div>
+ {:else}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool result" />
+ <div
+ class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"
+ class:text-error={rendered.chunk.isError}
+ >
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{rendered.chunk.toolName}</span>
+ {#if rendered.chunk.isError}
+ <span class="badge badge-error badge-xs">error</span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+ {:else}
+ <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
+ chat-start grid as the user bubble, so it inherits identical left spacing. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ {#if rendered.chunk.type === "text"}
+ <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
+ {:else if rendered.chunk.type === "error"}
+ <div class="text-error" role="alert">
+ {rendered.chunk.message}
+ {#if rendered.chunk.code}
+ <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
+ {/if}
+ </div>
+ {:else if rendered.chunk.type === "system"}
+ <div class="text-sm opacity-70">{rendered.chunk.text}</div>
+ {/if}
+ </div>
+ </div>
+ {/if}
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#if hasEarlier && onShowEarlier}
- <!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
- <div class="flex justify-center">
- <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}>
- {#if loadingEarlier}
- <span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
- Loading earlier messages…
- {:else}
- Show earlier messages
- {/if}
- </button>
- </div>
- {/if}
- {#each keyedRows as { row, key } (key)}
- {#if row.kind === "step-metrics"}
- {@const sv = viewStepMetrics(row.step, row.index)}
- <div class="chat chat-start">
- <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="text-xs opacity-70">
- {sv.label} · {sv.tokensLabel}
- {#if sv.tps} · {sv.tps}{/if}
- {#if sv.genTotal} · {sv.genTotal}{/if}
- </div>
- </div>
- </div>
- {: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)}
- <div class="chat chat-start">
- <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="flex flex-col gap-1 text-xs">
- <div class="opacity-70">
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
- {#if turnView.tps} · {turnView.tps}{/if}
- {#if turnView.duration} · {turnView.duration}{/if}
- </div>
- <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span class="flex items-center gap-1">
- <span class="opacity-70">Last turn:</span>
- <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span>
- </span>
- <span class="flex items-center gap-1">
- <span class="opacity-70">Chat Total:</span>
- <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span>
- </span>
- {#if retention}
- <span class="flex items-center gap-1">
- <span class="opacity-70">Retention:</span>
- <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
- </span>
- {/if}
- </div>
- </div>
- </div>
- </div>
- {:else if row.group.kind === "single"}
- {@render chunkRow(row.group.chunk)}
- {:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry (entry.call.toolCallId)}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{entry.call.toolName}</span>
- {#if entry.result?.isError}
- <span class="badge badge-error badge-xs">error</span>
- {:else if entry.result === null}
- <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre>
- {#if entry.result}
- <pre class="mt-1 max-h-96 overflow-auto text-xs" class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </div>
- </div>
- {/each}
- </div>
- </div>
- </div>
- {/if}
- {/each}
- {#if providerRetry}
- {@const rv = viewProviderRetry(providerRetry)}
- <!-- Transient yellow warning: a provider error is being retried with backoff.
- NOT a message chunk (never persisted/replayed) — a live UI notification only,
- shown where the reply would appear. Coalesces to the newest attempt + delay,
- and is cleared (foldEvent) when content resumes or the turn ends. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div class="chat-bubble w-full bg-transparent">
- <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
- <div class="flex flex-wrap items-center gap-2 font-medium">
- <span aria-hidden="true">⚠</span>
- <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
- {#if rv.code}
- <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
- {/if}
- </div>
- <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
- </div>
- </div>
- </div>
- {/if}
+ {#if hasEarlier && onShowEarlier}
+ <!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
+ <div class="flex justify-center">
+ <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}>
+ {#if loadingEarlier}
+ <span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
+ Loading earlier messages…
+ {:else}
+ Show earlier messages
+ {/if}
+ </button>
+ </div>
+ {/if}
+ {#each keyedRows as { row, key } (key)}
+ {#if row.kind === "step-metrics"}
+ {@const sv = viewStepMetrics(row.step, row.index)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="text-xs opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps}
+ · {sv.tps}{/if}
+ {#if sv.genTotal}
+ · {sv.genTotal}{/if}
+ </div>
+ </div>
+ </div>
+ {: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)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex flex-col gap-1 text-xs">
+ <div class="opacity-70">
+ {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
+ {#if turnView.tps}
+ · {turnView.tps}{/if}
+ {#if turnView.duration}
+ · {turnView.duration}{/if}
+ </div>
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Last turn:</span>
+ <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span>
+ </span>
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Chat Total:</span>
+ <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span>
+ </span>
+ {#if retention}
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Retention:</span>
+ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
+ </span>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+ {:else if row.group.kind === "single"}
+ {@render chunkRow(row.group.chunk)}
+ {:else}
+ <!-- Batched tool calls (one step): each entry is a collapsible card.
+ Click to expand and see the input/output. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="flex flex-col gap-1">
+ {#each row.group.entries as entry (entry.call.toolCallId)}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool call" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{entry.call.toolName}</span>
+ {#if entry.result?.isError}
+ <span class="badge badge-error badge-xs">error</span>
+ {:else if entry.result === null}
+ <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(
+ entry.call.input,
+ null,
+ 2,
+ )}</pre>
+ {#if entry.result}
+ <pre
+ class="mt-1 max-h-96 overflow-auto text-xs"
+ class:text-error={entry.result.isError}>{entry.result.content}</pre>
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ {/if}
+ {/each}
+ {#if providerRetry}
+ {@const rv = viewProviderRetry(providerRetry)}
+ <!-- Transient yellow warning: a provider error is being retried with backoff.
+ NOT a message chunk (never persisted/replayed) — a live UI notification only,
+ shown where the reply would appear. Coalesces to the newest attempt + delay,
+ and is cleared (foldEvent) when content resumes or the turn ends. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
+ <div class="flex flex-wrap items-center gap-2 font-medium">
+ <span aria-hidden="true">⚠</span>
+ <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
+ {#if rv.code}
+ <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
+ {/if}
+ </div>
+ <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
+ </div>
+ </div>
+ </div>
+ {/if}
</div>
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 @@
<script lang="ts">
- export type CompactNowResult =
- | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
- | { readonly ok: false; readonly error: string };
+ export type CompactNowResult =
+ | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
+ | { readonly ok: false; readonly error: string };
- export type SaveCompactPercentResult =
- | { readonly ok: true; readonly percent: number }
- | { readonly ok: false; readonly error: string };
+ export type SaveCompactPercentResult =
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
- let {
- percent,
- canCompact,
- compactNow,
- savePercent,
- }: {
- /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */
- percent: number | null;
- /** Whether a real conversation is focused (a draft has nothing to compact). */
- canCompact: boolean;
- compactNow: () => Promise<CompactNowResult | null>;
- savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
- } = $props();
+ let {
+ percent,
+ canCompact,
+ compactNow,
+ savePercent,
+ }: {
+ /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */
+ percent: number | null;
+ /** Whether a real conversation is focused (a draft has nothing to compact). */
+ canCompact: boolean;
+ compactNow: () => Promise<CompactNowResult | null>;
+ savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
+ } = $props();
- const DEFAULT_PERCENT = 85;
+ const DEFAULT_PERCENT = 85;
- let compacting = $state(false);
- let compactError = $state<string | null>(null);
- let compactResult = $state<{ summarized: number; kept: number } | null>(null);
+ let compacting = $state(false);
+ let compactError = $state<string | null>(null);
+ let compactResult = $state<{ summarized: number; kept: number } | null>(null);
- let percentInput = $state("");
- let savingPercent = $state(false);
- let percentError = $state<string | null>(null);
- let percentSaved = $state(false);
+ let percentInput = $state("");
+ let savingPercent = $state(false);
+ let percentError = $state<string | null>(null);
+ let percentSaved = $state(false);
- // Sync the input from the prop when it changes (focus switch / initial load).
- let lastPercent = $state<number | null>(null);
- $effect(() => {
- if (percent !== lastPercent) {
- lastPercent = percent;
- percentInput = percent !== null ? String(percent) : "";
- percentError = null;
- percentSaved = false;
- }
- });
+ // Sync the input from the prop when it changes (focus switch / initial load).
+ let lastPercent = $state<number | null>(null);
+ $effect(() => {
+ if (percent !== lastPercent) {
+ lastPercent = percent;
+ percentInput = percent !== null ? String(percent) : "";
+ percentError = null;
+ percentSaved = false;
+ }
+ });
- const percentLabel = $derived(
- percent == null
- ? "Loading…"
- : percent === 0
- ? "Disabled (manual only)"
- : percent === DEFAULT_PERCENT
- ? `${percent}% (default)`
- : `${percent}%`,
- );
+ const percentLabel = $derived(
+ percent == null
+ ? "Loading…"
+ : percent === 0
+ ? "Disabled (manual only)"
+ : percent === DEFAULT_PERCENT
+ ? `${percent}% (default)`
+ : `${percent}%`,
+ );
- async function handleCompact() {
- if (compacting || !canCompact) return;
- compacting = true;
- compactError = null;
- compactResult = null;
- const result = await compactNow();
- compacting = false;
- if (result === null) return;
- if (result.ok) {
- compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
- } else {
- compactError = result.error;
- }
- }
+ async function handleCompact() {
+ if (compacting || !canCompact) return;
+ compacting = true;
+ compactError = null;
+ compactResult = null;
+ const result = await compactNow();
+ compacting = false;
+ if (result === null) return;
+ if (result.ok) {
+ compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
+ } else {
+ compactError = result.error;
+ }
+ }
- async function handleSavePercent() {
- const value = Number.parseInt(percentInput, 10);
- if (Number.isNaN(value) || value < 0 || value > 100) {
- percentError = "Must be 0-100";
- return;
- }
- savingPercent = true;
- percentError = null;
- percentSaved = false;
- const result = await savePercent(value);
- savingPercent = false;
- if (result === null) return;
- if (result.ok) {
- percentSaved = true;
- } else {
- percentError = result.error;
- }
- }
+ async function handleSavePercent() {
+ const value = Number.parseInt(percentInput, 10);
+ if (Number.isNaN(value) || value < 0 || value > 100) {
+ percentError = "Must be 0-100";
+ return;
+ }
+ savingPercent = true;
+ percentError = null;
+ percentSaved = false;
+ const result = await savePercent(value);
+ savingPercent = false;
+ if (result === null) return;
+ if (result.ok) {
+ percentSaved = true;
+ } else {
+ percentError = result.error;
+ }
+ }
</script>
<div class="flex flex-col gap-3">
- <!-- Manual compaction -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={!canCompact || compacting}
- onclick={handleCompact}
- >
- {#if compacting}
- <span class="loading loading-spinner loading-xs"></span>
- Compacting…
- {:else}
- Compact now
- {/if}
- </button>
- {#if !canCompact}
- <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
- {:else if compactError}
- <p class="text-xs text-error">{compactError}</p>
- {:else if compactResult}
- <p class="text-xs text-success">
- Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
- </p>
- {:else}
- <p class="text-xs opacity-50">
- Summarizes old messages into a system summary + retains the most recent messages.
- </p>
- {/if}
- </section>
+ <!-- Manual compaction -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canCompact || compacting}
+ onclick={handleCompact}
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact now
+ {/if}
+ </button>
+ {#if !canCompact}
+ <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
+ {:else if compactError}
+ <p class="text-xs text-error">{compactError}</p>
+ {:else if compactResult}
+ <p class="text-xs text-success">
+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
+ </p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Summarizes old messages into a system summary + retains the most recent messages.
+ </p>
+ {/if}
+ </section>
- <!-- Auto-compact percent -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
- <div class="flex items-center gap-2">
- <input
- type="number"
- class="input input-bordered input-sm w-24"
- min="0"
- max="100"
- placeholder={String(DEFAULT_PERCENT)}
- value={percentInput}
- disabled={savingPercent}
- onchange={handleSavePercent}
- aria-label="Compact percent (0-100)"
- />
- <span class="text-xs opacity-60">%</span>
- {#if savingPercent}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
- <p class="text-xs opacity-50">
- Current: {percentLabel}
- <br />
- 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
- </p>
- {#if percentError}
- <p class="text-xs text-error">{percentError}</p>
- {:else if percentSaved}
- <p class="text-xs text-success">Saved.</p>
- {/if}
- </section>
+ <!-- Auto-compact percent -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ min="0"
+ max="100"
+ placeholder={String(DEFAULT_PERCENT)}
+ value={percentInput}
+ disabled={savingPercent}
+ onchange={handleSavePercent}
+ aria-label="Compact percent (0-100)"
+ />
+ <span class="text-xs opacity-60">%</span>
+ {#if savingPercent}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <p class="text-xs opacity-50">
+ Current: {percentLabel}
+ <br />
+ 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
+ </p>
+ {#if percentError}
+ <p class="text-xs text-error">{percentError}</p>
+ {:else if percentSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
</div>
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 @@
<script lang="ts">
- import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
- const FALLBACK_CONTEXT_WINDOW = 1_000_000;
- const MAX_LINES = 7;
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
+ const MAX_LINES = 7;
- let {
- onSend,
- onQueue,
- onStop,
- contextSize = undefined,
- contextWindow = undefined,
- status = "idle",
- }: {
- onSend: (text: string) => void;
- /**
- * Enqueue a steering message (`chat.queue`). When provided AND the status
- * is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. When absent, `onSend` is
- * used regardless (tests / non-steering contexts).
- */
- onQueue?: (text: string) => void;
- /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
- onStop?: () => void;
- // Current context occupancy (latest turn's contextSize), or `undefined`
- // when unknown — the status bar then shows "— tokens", never 0%.
- contextSize?: number | undefined;
- /** Per-model context window (max tokens) from `GET /models` modelInfo. */
- contextWindow?: number | undefined;
- // Coarse agent status for the status-bar icon.
- status?: "idle" | "running" | "error";
- } = $props();
+ let {
+ onSend,
+ onQueue,
+ onStop,
+ contextSize = undefined,
+ contextWindow = undefined,
+ status = "idle",
+ }: {
+ onSend: (text: string) => void;
+ /**
+ * Enqueue a steering message (`chat.queue`). When provided AND the status
+ * is `running`, the send button becomes a "Queue" button that steers the
+ * in-flight turn instead of starting a new one. When absent, `onSend` is
+ * used regardless (tests / non-steering contexts).
+ */
+ onQueue?: (text: string) => void;
+ /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
+ onStop?: () => void;
+ // Current context occupancy (latest turn's contextSize), or `undefined`
+ // when unknown — the status bar then shows "— tokens", never 0%.
+ contextSize?: number | undefined;
+ /** Per-model context window (max tokens) from `GET /models` modelInfo. */
+ contextWindow?: number | undefined;
+ // Coarse agent status for the status-bar icon.
+ status?: "idle" | "running" | "error";
+ } = $props();
- let text = $state("");
- let inputEl: HTMLTextAreaElement | undefined;
+ let text = $state("");
+ let inputEl: HTMLTextAreaElement | undefined;
- const hasText = $derived(text.trim().length > 0);
- const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
- const usage = $derived(computeContextUsage(contextSize, effectiveMax));
- const hasUsage = $derived(contextSize !== undefined);
+ const hasText = $derived(text.trim().length > 0);
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
+ const hasUsage = $derived(contextSize !== undefined);
- // One button, three modes:
- // - idle → "Send" (starts a turn via chat.send)
- // - running + text → "Queue" (steers via chat.queue)
- // - running + empty → "Stop" (aborts via POST /stop)
- const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
- if (status === "running" && !hasText && onStop !== undefined) return "stop";
- if (status === "running" && hasText && onQueue !== undefined) return "queue";
- return "send";
- });
- const placeholder = $derived(status === "running" ? "Steer the conversation..." : "Type a message...");
+ // One button, three modes:
+ // - idle → "Send" (starts a turn via chat.send)
+ // - running + text → "Queue" (steers via chat.queue)
+ // - running + empty → "Stop" (aborts via POST /stop)
+ const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
+ if (status === "running" && !hasText && onStop !== undefined) return "stop";
+ if (status === "running" && hasText && onQueue !== undefined) return "queue";
+ return "send";
+ });
+ const placeholder = $derived(
+ status === "running" ? "Steer the conversation..." : "Type a message...",
+ );
- // As the window fills, escalate color: calm → warning → danger.
- function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
- }
+ // As the window fills, escalate color: calm → warning → danger.
+ function fillClass(pct: number): string {
+ if (pct >= 90) return "progress-error";
+ if (pct >= 70) return "progress-warning";
+ return "progress-success";
+ }
- function resize(): void {
- const el = inputEl;
- if (!el) return;
- el.style.height = "auto";
- const style = getComputedStyle(el);
- const lineHeight = Number.parseFloat(style.lineHeight) || 20;
- const paddingY =
- Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
- const borderY =
- Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
- const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
- const next = Math.min(el.scrollHeight, maxHeight);
- el.style.height = `${next}px`;
- el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
- }
+ function resize(): void {
+ const el = inputEl;
+ if (!el) return;
+ el.style.height = "auto";
+ const style = getComputedStyle(el);
+ const lineHeight = Number.parseFloat(style.lineHeight) || 20;
+ const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
+ const borderY =
+ Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
+ const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
+ const next = Math.min(el.scrollHeight, maxHeight);
+ el.style.height = `${next}px`;
+ el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
+ }
- // Re-run resize whenever the value changes (covers programmatic clears too).
- $effect(() => {
- void text;
- resize();
- });
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
- function handleSubmit(): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- if (buttonMode === "queue") {
- onQueue?.(trimmed);
- } else {
- onSend(trimmed);
- }
- text = "";
- }
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ if (buttonMode === "queue") {
+ onQueue?.(trimmed);
+ } else {
+ onSend(trimmed);
+ }
+ text = "";
+ }
- function handleKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
</script>
<form
- class="flex flex-col"
- onsubmit={(e) => {
- e.preventDefault();
- handleSubmit();
- }}
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
>
- <!-- Top bar: expanding textarea + single context-aware button -->
- <div class="flex items-end gap-2 px-4 pt-3 pb-2">
- <textarea
- bind:this={inputEl}
- class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
- bind:value={text}
- onkeydown={handleKeydown}
- placeholder={placeholder}
- rows="1"
- aria-label="Message input"
- ></textarea>
- {#if buttonMode === "stop"}
- <button
- class="btn btn-error w-20 shrink-0"
- type="button"
- aria-label="Stop generation"
- onclick={() => onStop?.()}
- >
- Stop
- </button>
- {:else}
- <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
- {buttonMode === "queue" ? "Queue" : "Send"}
- </button>
- {/if}
- </div>
+ <!-- Top bar: expanding textarea + single context-aware button -->
+ <div class="flex items-end gap-2 px-4 pt-3 pb-2">
+ <textarea
+ bind:this={inputEl}
+ class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
+ bind:value={text}
+ onkeydown={handleKeydown}
+ {placeholder}
+ rows="1"
+ aria-label="Message input"></textarea>
+ {#if buttonMode === "stop"}
+ <button
+ class="btn btn-error w-20 shrink-0"
+ type="button"
+ aria-label="Stop generation"
+ onclick={() => onStop?.()}
+ >
+ Stop
+ </button>
+ {:else}
+ <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
+ {buttonMode === "queue" ? "Queue" : "Send"}
+ </button>
+ {/if}
+ </div>
- <!-- Bottom status bar: status icon · context-window fill · token count -->
- <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
- <span class="shrink-0">
- {#if status === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
- {:else if status === "error"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-error"
- aria-label="Error"
- >
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- {:else}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2.5"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-success"
- aria-label="Idle"
- >
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {/if}
- </span>
+ <!-- Bottom status bar: status icon · context-window fill · token count -->
+ <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
+ <span class="shrink-0">
+ {#if status === "running"}
+ <span class="loading loading-spinner loading-xs text-primary"></span>
+ {:else if status === "error"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-error"
+ aria-label="Error"
+ >
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="8" x2="12" y2="12"></line>
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
+ </svg>
+ {:else}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ aria-label="Idle"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {/if}
+ </span>
- {#if usage.percent !== null}
- <progress
- class="progress h-2 flex-1 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
- {/if}
+ {#if usage.percent !== null}
+ <progress
+ class="progress h-2 flex-1 {fillClass(usage.percent)}"
+ value={usage.percent}
+ max="100"
+ ></progress>
+ {:else}
+ <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
+ {/if}
- <span class="shrink-0 whitespace-nowrap font-mono">
- {#if hasUsage}
- {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
- class="text-base-content/40"
- >
- / {formatCompactTokens(usage.max)}</span
- >{/if}
- {#if usage.percent !== null}
- <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
- {/if}
- {:else}
- <span class="text-base-content/40">— tokens</span>
- {/if}
- </span>
- </div>
+ <span class="shrink-0 whitespace-nowrap font-mono">
+ {#if hasUsage}
+ {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
+ class="text-base-content/40"
+ >
+ / {formatCompactTokens(usage.max)}</span
+ >{/if}
+ {#if usage.percent !== null}
+ <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
+ {/if}
+ {:else}
+ <span class="text-base-content/40">— tokens</span>
+ {/if}
+ </span>
+ </div>
</form>
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 @@
<script lang="ts">
- import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
- let {
- models,
- selected,
- onSelect,
- }: {
- models: readonly string[];
- selected: string;
- onSelect: (model: string) => void;
- } = $props();
+ let {
+ models,
+ selected,
+ onSelect,
+ }: {
+ models: readonly string[];
+ selected: string;
+ onSelect: (model: string) => void;
+ } = $props();
- const keys = $derived(modelKeys(models));
- const current = $derived(splitModelName(selected));
- const keyModels = $derived(modelsForKey(models, current.key));
+ const keys = $derived(modelKeys(models));
+ const current = $derived(splitModelName(selected));
+ const keyModels = $derived(modelsForKey(models, current.key));
- // Switching key jumps to the first model available under it.
- function selectKey(key: string): void {
- const first = modelsForKey(models, key)[0] ?? "";
- onSelect(joinModelName(key, first));
- }
+ // Switching key jumps to the first model available under it.
+ function selectKey(key: string): void {
+ const first = modelsForKey(models, key)[0] ?? "";
+ onSelect(joinModelName(key, first));
+ }
- function selectModel(model: string): void {
- onSelect(joinModelName(current.key, model));
- }
+ function selectModel(model: string): void {
+ onSelect(joinModelName(current.key, model));
+ }
</script>
<div class="flex flex-col gap-2">
- <select
- class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
- aria-label="Key selector"
- >
- {#each keys as key (key)}
- <option value={key}>{key}</option>
- {/each}
- </select>
- <select
- class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
- aria-label="Model selector"
- >
- {#each keyModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
+ <select
+ class="select w-full"
+ value={current.key}
+ onchange={(e) => selectKey(e.currentTarget.value)}
+ aria-label="Key selector"
+ >
+ {#each keys as key (key)}
+ <option value={key}>{key}</option>
+ {/each}
+ </select>
+ <select
+ class="select w-full"
+ value={current.model}
+ onchange={(e) => selectModel(e.currentTarget.value)}
+ aria-label="Model selector"
+ >
+ {#each keyModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ </select>
</div>
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 @@
<script lang="ts">
- import type { ReasoningEffort } from "@dispatch/transport-contract";
- import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
- } from "../reasoning-effort";
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import {
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ type SaveReasoningEffort,
+ } from "../reasoning-effort";
- let {
- persisted,
- save,
- }: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
- } = $props();
+ let {
+ persisted,
+ save,
+ }: {
+ /** The conversation's persisted level, or null when never set (default applies). */
+ persisted: ReasoningEffort | null;
+ save: SaveReasoningEffort;
+ } = $props();
- const options = effortOptions();
+ const options = effortOptions();
- // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- let chosen = $state<ReasoningEffort | null>(null);
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
+ // Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ReasoningEffort | null>(null);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(chosen ?? effectiveEffort(persisted));
- async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
- chosen = value;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(value);
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- chosen = null; // revert to the persisted value
- }
- }
+ async function handleChange(value: string) {
+ if (!isReasoningEffort(value) || saving) return;
+ chosen = value;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(value);
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ chosen = null; // revert to the persisted value
+ }
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
- <div class="flex items-center gap-2">
- <select
- class="select select-sm w-full"
- value={selected}
- disabled={saving}
- onchange={(e) => handleChange(e.currentTarget.value)}
- aria-label="Reasoning effort"
- >
- {#each options as option (option.value)}
- <option value={option.value}>{option.label}</option>
- {/each}
- </select>
- {#if saving}
- <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span>
- {/if}
- </div>
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved}
- <p class="text-xs text-success">Saved — applies from the next turn.</p>
- {:else}
- <p class="text-xs opacity-50">
- How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
- </p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
+ <div class="flex items-center gap-2">
+ <select
+ class="select select-sm w-full"
+ value={selected}
+ disabled={saving}
+ onchange={(e) => handleChange(e.currentTarget.value)}
+ aria-label="Reasoning effort"
+ >
+ {#each options as option (option.value)}
+ <option value={option.value}>{option.label}</option>
+ {/each}
+ </select>
+ {#if saving}
+ <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span>
+ {/if}
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved}
+ <p class="text-xs text-success">Saved — applies from the next turn.</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
+ </p>
+ {/if}
</div>
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> = {}): 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("[email protected]");
- });
+ it("is user@host with no port when port is the SSH default (22)", () => {
+ expect(formatHost(computer({ port: 22 }))).toBe("[email protected]");
+ });
- it("appends a non-default port", () => {
- expect(formatHost(computer({ port: 2222 }))).toBe("[email protected]:2222");
- });
+ it("appends a non-default port", () => {
+ expect(formatHost(computer({ port: 2222 }))).toBe("[email protected]: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("[email protected]");
- 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("[email protected]");
+ 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("[email protected]");
- });
+ 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("[email protected]");
+ });
});
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> = {},
- ): 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> = {},
+ ): 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<ComputerSaveResult | null>;
/** 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<ComputerListResult | null>;
/** 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<ComputerStatusResult | null>;
/** 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<TestComputerResult | null>;
@@ -55,16 +55,16 @@ export type TestComputer = (alias: string) => Promise<TestComputerResult | null>
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 @@
<script lang="ts">
- import type { ComputerStatusResponse } from "@dispatch/transport-contract";
- import ComputerSelect from "./ComputerSelect.svelte";
- import {
- viewComputerStatus,
- type ComputerStatusView,
- type LoadComputerStatus,
- type SaveComputer,
- type TestComputer,
-} from "../logic/view-model";
- import type { ComputerEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
-
- let {
- computerId,
- canEdit,
- computers,
- save,
- loadStatus,
- test,
- }: {
- /** The active conversation's persisted computer alias, or null (local/inherit). */
- computerId: string | null;
- /** Whether a real conversation is focused (a draft can't persist yet). */
- canEdit: boolean;
- /** Discovered computers from `GET /computers` (read-only). */
- computers: readonly ComputerEntry[];
- save: SaveComputer;
- loadStatus: LoadComputerStatus;
- test: TestComputer;
- } = $props();
-
- // ── Save: selecting a dropdown option persists immediately (PUT /computer). ──
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
-
- async function select(computerId: string | null) {
- if (saving || !canEdit) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(computerId);
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- }
- }
-
- // ── Connection status: poll the selected computer's live state. Owned + ──
- // disposed here (never leaks across conversations — the field re-mounts per
- // conversation via the {#key} in App.svelte, like CwdField). No poll while
- // local (no alias) or while a draft can't persist.
- let statusView = $state<ComputerStatusView | null>(null);
- let statusError = $state<string | null>(null);
-
- async function refreshStatus() {
- const alias = untrack(() => computerId);
- if (alias === null) {
- statusView = null;
- statusError = null;
- return;
- }
- const result = await loadStatus(alias);
- if (result === null) return;
- if (result.ok) {
- statusView = viewComputerStatus(result.status);
- statusError = null;
- } else {
- statusView = null;
- statusError = result.error;
- }
- }
-
- // Re-fetch on mount + whenever the selected alias changes (incl. after a save).
- // Clear the test result so a stale ✓ from alias A doesn't persist for alias B.
- $effect(() => {
- void computerId;
- testResult = null;
- void refreshStatus();
- });
-
- // Poll the status while a computer is selected. `connecting` is transient, so
- // a faster cadence helps it flip to `connected`; a connected host is stable.
- const POLL_MS = 4000;
- $effect(() => {
- const alias = computerId;
- if (alias === null) return;
- const handle = setInterval(() => void refreshStatus(), POLL_MS);
- return () => clearInterval(handle);
- });
-
- // ── Test connection: one-shot probe (POST /computers/:alias/test). ──────────
- let testing = $state(false);
- let testResult = $state<{ ok: boolean; error: string | null } | null>(null);
-
- async function runTest() {
- const alias = untrack(() => computerId);
- if (alias === null || testing) return;
- testing = true;
- testResult = null;
- const result = await test(alias);
- testing = false;
- if (result === null) return;
- testResult = result.ok
- ? { ok: true, error: null }
- : { ok: false, error: result.error };
- // Refresh the connection-status badge so it reflects the post-test state
- // (clears a stale "connecting" spinner caught by the poll mid-test).
- void refreshStatus();
- }
-
- const badgeClass = $derived.by(() => {
- const b = statusView?.badge ?? "neutral";
- switch (b) {
- case "success":
- return "badge-success";
- case "warning":
- return "badge-warning";
- case "error":
- return "badge-error";
- default:
- return "badge-ghost";
- }
- });
+ import type { ComputerStatusResponse } from "@dispatch/transport-contract";
+ import ComputerSelect from "./ComputerSelect.svelte";
+ import {
+ viewComputerStatus,
+ type ComputerStatusView,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ } from "../logic/view-model";
+ import type { ComputerEntry } from "@dispatch/wire";
+ import { untrack } from "svelte";
+
+ let {
+ computerId,
+ canEdit,
+ computers,
+ save,
+ loadStatus,
+ test,
+ }: {
+ /** The active conversation's persisted computer alias, or null (local/inherit). */
+ computerId: string | null;
+ /** Whether a real conversation is focused (a draft can't persist yet). */
+ canEdit: boolean;
+ /** Discovered computers from `GET /computers` (read-only). */
+ computers: readonly ComputerEntry[];
+ save: SaveComputer;
+ loadStatus: LoadComputerStatus;
+ test: TestComputer;
+ } = $props();
+
+ // ── Save: selecting a dropdown option persists immediately (PUT /computer). ──
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+
+ async function select(computerId: string | null) {
+ if (saving || !canEdit) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(computerId);
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
+
+ // ── Connection status: poll the selected computer's live state. Owned + ──
+ // disposed here (never leaks across conversations — the field re-mounts per
+ // conversation via the {#key} in App.svelte, like CwdField). No poll while
+ // local (no alias) or while a draft can't persist.
+ let statusView = $state<ComputerStatusView | null>(null);
+ let statusError = $state<string | null>(null);
+
+ async function refreshStatus() {
+ const alias = untrack(() => computerId);
+ if (alias === null) {
+ statusView = null;
+ statusError = null;
+ return;
+ }
+ const result = await loadStatus(alias);
+ if (result === null) return;
+ if (result.ok) {
+ statusView = viewComputerStatus(result.status);
+ statusError = null;
+ } else {
+ statusView = null;
+ statusError = result.error;
+ }
+ }
+
+ // Re-fetch on mount + whenever the selected alias changes (incl. after a save).
+ // Clear the test result so a stale ✓ from alias A doesn't persist for alias B.
+ $effect(() => {
+ void computerId;
+ testResult = null;
+ void refreshStatus();
+ });
+
+ // Poll the status while a computer is selected. `connecting` is transient, so
+ // a faster cadence helps it flip to `connected`; a connected host is stable.
+ const POLL_MS = 4000;
+ $effect(() => {
+ const alias = computerId;
+ if (alias === null) return;
+ const handle = setInterval(() => void refreshStatus(), POLL_MS);
+ return () => clearInterval(handle);
+ });
+
+ // ── Test connection: one-shot probe (POST /computers/:alias/test). ──────────
+ let testing = $state(false);
+ let testResult = $state<{ ok: boolean; error: string | null } | null>(null);
+
+ async function runTest() {
+ const alias = untrack(() => computerId);
+ if (alias === null || testing) return;
+ testing = true;
+ testResult = null;
+ const result = await test(alias);
+ testing = false;
+ if (result === null) return;
+ testResult = result.ok ? { ok: true, error: null } : { ok: false, error: result.error };
+ // Refresh the connection-status badge so it reflects the post-test state
+ // (clears a stale "connecting" spinner caught by the poll mid-test).
+ void refreshStatus();
+ }
+
+ const badgeClass = $derived.by(() => {
+ const b = statusView?.badge ?? "neutral";
+ switch (b) {
+ case "success":
+ return "badge-success";
+ case "warning":
+ return "badge-warning";
+ case "error":
+ return "badge-error";
+ default:
+ return "badge-ghost";
+ }
+ });
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Computer (SSH)</span>
- <div class="flex items-center gap-2">
- <ComputerSelect
- value={computerId}
- {computers}
- disabled={!canEdit || saving}
- onSelect={select}
- />
- {#if saving}
- <span class="loading loading-spinner loading-xs shrink-0"></span>
- {/if}
- </div>
-
- {#if !canEdit}
- <p class="text-xs opacity-60">Start or open a conversation to set its computer.</p>
- {:else if computerId !== null}
- <!-- Connection status badge + Test affordance for the selected computer. -->
- <div class="flex flex-wrap items-center gap-2">
- {#if statusView}
- <span class="badge badge-sm {badgeClass}" title={statusView.error ?? statusView.statusLabel}>
- {#if statusView.busy}
- <span class="loading loading-spinner loading-[10px]"></span>
- {/if}
- {statusView.statusLabel}
- </span>
- {:else if statusError}
- <span class="badge badge-sm badge-error" title={statusError}>Status error</span>
- {:else}
- <span class="badge badge-sm badge-ghost">—</span>
- {/if}
-
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={testing}
- onclick={runTest}
- title={testResult
- ? testResult.ok
- ? "Connection OK"
- : testResult.error ?? "Failed"
- : "Test connection"}
- >
- {#if testing}
- <span class="loading loading-spinner loading-[10px]"></span>
- {:else if testResult?.ok}
- <span class="text-success">✓</span>
- {:else if testResult}
- <span class="text-error">✗</span>
- {:else}
- Test
- {/if}
- </button>
-
- {#if testResult}
- <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
- {testResult.ok ? "OK" : testResult.error ?? "Failed"}
- </span>
- {/if}
- </div>
- {#if statusView?.error}
- <p class="text-xs text-error">{statusView.error}</p>
- {/if}
- {:else if computers.length === 0}
- <p class="text-xs opacity-50">
- No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer.
- </p>
- {:else if justSaved && !error}
- <p class="text-xs text-success">Saved.</p>
- {/if}
-
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {/if}
-
- <p class="text-xs opacity-50">
- Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. Not seen by the agent.
- </p>
+ <span class="text-xs font-semibold uppercase opacity-60">Computer (SSH)</span>
+ <div class="flex items-center gap-2">
+ <ComputerSelect
+ value={computerId}
+ {computers}
+ disabled={!canEdit || saving}
+ onSelect={select}
+ />
+ {#if saving}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ {#if !canEdit}
+ <p class="text-xs opacity-60">Start or open a conversation to set its computer.</p>
+ {:else if computerId !== null}
+ <!-- Connection status badge + Test affordance for the selected computer. -->
+ <div class="flex flex-wrap items-center gap-2">
+ {#if statusView}
+ <span
+ class="badge badge-sm {badgeClass}"
+ title={statusView.error ?? statusView.statusLabel}
+ >
+ {#if statusView.busy}
+ <span class="loading loading-spinner loading-[10px]"></span>
+ {/if}
+ {statusView.statusLabel}
+ </span>
+ {:else if statusError}
+ <span class="badge badge-sm badge-error" title={statusError}>Status error</span>
+ {:else}
+ <span class="badge badge-sm badge-ghost">—</span>
+ {/if}
+
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={testing}
+ onclick={runTest}
+ title={testResult
+ ? testResult.ok
+ ? "Connection OK"
+ : (testResult.error ?? "Failed")
+ : "Test connection"}
+ >
+ {#if testing}
+ <span class="loading loading-spinner loading-[10px]"></span>
+ {:else if testResult?.ok}
+ <span class="text-success">✓</span>
+ {:else if testResult}
+ <span class="text-error">✗</span>
+ {:else}
+ Test
+ {/if}
+ </button>
+
+ {#if testResult}
+ <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
+ {testResult.ok ? "OK" : (testResult.error ?? "Failed")}
+ </span>
+ {/if}
+ </div>
+ {#if statusView?.error}
+ <p class="text-xs text-error">{statusView.error}</p>
+ {/if}
+ {:else if computers.length === 0}
+ <p class="text-xs opacity-50">
+ No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer.
+ </p>
+ {:else if justSaved && !error}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {/if}
+
+ <p class="text-xs opacity-50">
+ Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH.
+ Not seen by the agent.
+ </p>
</div>
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 @@
<script lang="ts">
- import type { ComputerEntry } from "@dispatch/wire";
+ import type { ComputerEntry } from "@dispatch/wire";
- let {
- value,
- computers,
- disabled = false,
- onSelect,
- }: {
- /** The currently selected computer alias, or null for "Local (none)". */
- value: string | null;
- /** Discovered computers from `GET /computers` (read-only). */
- computers: readonly ComputerEntry[];
- disabled?: boolean;
- onSelect: (computerId: string | null) => void;
- } = $props();
+ let {
+ value,
+ computers,
+ disabled = false,
+ onSelect,
+ }: {
+ /** The currently selected computer alias, or null for "Local (none)". */
+ value: string | null;
+ /** Discovered computers from `GET /computers` (read-only). */
+ computers: readonly ComputerEntry[];
+ disabled?: boolean;
+ onSelect: (computerId: string | null) => void;
+ } = $props();
- // A `<select>` value is a string; map "" ↔ null (Local). The chosen option's
- // value is the alias, with "" meaning "clear / local".
- const selectValue = $derived(value ?? "");
+ // A `<select>` value is a string; map "" ↔ null (Local). The chosen option's
+ // value is the alias, with "" meaning "clear / local".
+ const selectValue = $derived(value ?? "");
- function onChange(e: Event) {
- const v = (e.currentTarget as HTMLSelectElement).value;
- onSelect(v === "" ? null : v);
- }
+ function onChange(e: Event) {
+ const v = (e.currentTarget as HTMLSelectElement).value;
+ onSelect(v === "" ? null : v);
+ }
</script>
<select
- class="select select-bordered select-sm w-full font-mono text-xs"
- value={selectValue}
- disabled={disabled}
- onchange={onChange}
- aria-label="Computer"
+ class="select select-bordered select-sm w-full font-mono text-xs"
+ value={selectValue}
+ {disabled}
+ onchange={onChange}
+ aria-label="Computer"
>
- <option value="">Local (none)</option>
- {#each computers as c (c.alias)}
- <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option>
- {/each}
+ <option value="">Local (none)</option>
+ {#each computers as c (c.alias)}
+ <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option>
+ {/each}
</select>
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<string, StoredChunk[]>();
-
- 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<string, StoredChunk[]>();
+
+ 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<readonly StoredChunk[]>;
+ /** Load all cached chunks for a conversation. */
+ load(conversationId: string): Promise<readonly StoredChunk[]>;
- /**
- * Load + reconcile + append new chunks.
- * Returns the merged cache (the new authoritative cache for this conversation).
- */
- commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
+ /**
+ * Load + reconcile + append new chunks.
+ * Returns the merged cache (the new authoritative cache for this conversation).
+ */
+ commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
- /** Return the `?sinceSeq=` cursor for the next incremental sync. */
- sinceSeq(conversationId: string): Promise<number>;
+ /** Return the `?sinceSeq=` cursor for the next incremental sync. */
+ sinceSeq(conversationId: string): Promise<number>;
- /**
- * Evict conversations over budget.
- * Returns the evicted conversationIds.
- */
- evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
+ /**
+ * Evict conversations over budget.
+ * Returns the evicted conversationIds.
+ */
+ evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
- /** Delete all cached data for a single conversation (local forget). */
- delete(conversationId: string): Promise<void>;
+ /** Delete all cached data for a single conversation (local forget). */
+ delete(conversationId: string): Promise<void>;
}
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<number>();
- for (const chunk of cached) {
- seen.add(chunk.seq);
- }
+ const seen = new Set<number>();
+ 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<readonly StoredChunk[]>;
+ /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
+ load(conversationId: string): Promise<readonly StoredChunk[]>;
- /**
- * 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<void>;
+ /**
+ * 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<void>;
- /** Delete all cached data for a conversation. */
- delete(conversationId: string): Promise<void>;
+ /** Delete all cached data for a conversation. */
+ delete(conversationId: string): Promise<void>;
- /** Return metadata for all cached conversations (for eviction). */
- index(): Promise<readonly ConversationCacheIndexEntry[]>;
+ /** Return metadata for all cached conversations (for eviction). */
+ index(): Promise<readonly ConversationCacheIndexEntry[]>;
}
/** 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> = {}): 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<CwdSaveResult | null>;
/** 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<LspStatusResult | null>;
@@ -32,12 +32,12 @@ export type LoadLspStatus = () => Promise<LspStatusResult | null>;
/** 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 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model";
- let {
- cwd,
- canEdit,
- save,
- }: {
- /** The active conversation's persisted cwd, or null when unset. */
- cwd: string | null;
- /** Whether a real conversation is focused (a draft can't persist a cwd yet). */
- canEdit: boolean;
- save: SaveCwd;
- } = $props();
+ let {
+ cwd,
+ canEdit,
+ save,
+ }: {
+ /** The active conversation's persisted cwd, or null when unset. */
+ cwd: string | null;
+ /** Whether a real conversation is focused (a draft can't persist a cwd yet). */
+ canEdit: boolean;
+ save: SaveCwd;
+ } = $props();
- // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading
- // the prop directly into initial $state would only capture its first value.)
- let value = $state("");
- let lastSeed = $state("");
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
+ // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading
+ // the prop directly into initial $state would only capture its first value.)
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
- // Seed the input from the persisted cwd (it loads async). Only reseed while the
- // field is untouched, so an in-flight load can't clobber what the user typed.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- $effect(() => {
- const incoming = cwd ?? "";
- untrack(() => {
- if (value === lastSeed) value = incoming;
- lastSeed = incoming;
- });
- });
+ // Seed the input from the persisted cwd (it loads async). Only reseed while the
+ // field is untouched, so an in-flight load can't clobber what the user typed.
+ // Re-mounted per conversation, so there is no cross-tab bleed.
+ $effect(() => {
+ const incoming = cwd ?? "";
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
- const dirty = $derived(cwdChanged(value, cwd));
+ const dirty = $derived(cwdChanged(value, cwd));
- async function handleSave() {
- if (saving || !canEdit || !dirty) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(normalizeCwd(value));
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- }
- }
+ async function handleSave() {
+ if (saving || !canEdit || !dirty) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(normalizeCwd(value));
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
- function onInput() {
- justSaved = false;
- error = null;
- }
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Working directory</span>
- <div class="flex items-center gap-2">
- <input
- type="text"
- class="input input-bordered input-sm w-full font-mono text-xs"
- placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"}
- bind:value
- disabled={!canEdit || saving}
- oninput={onInput}
- onkeydown={(e) => {
- if (e.key === "Enter") handleSave();
- }}
- aria-label="Working directory"
- />
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={!canEdit || saving || !dirty}
- onclick={handleSave}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
- {#if !canEdit}
- <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p>
- {:else if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved && !dirty}
- <p class="text-xs text-success">Saved.</p>
- {:else}
- <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Working directory</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"}
+ bind:value
+ disabled={!canEdit || saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Working directory"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={!canEdit || saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if !canEdit}
+ <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved.</p>
+ {:else}
+ <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p>
+ {/if}
</div>
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 @@
<script lang="ts">
- import { untrack } from "svelte";
- import {
- type Badge,
- type LoadLspStatus,
- type LspServerView,
- summarizeServers,
- viewLspServers,
- } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import {
+ type Badge,
+ type LoadLspStatus,
+ type LspServerView,
+ summarizeServers,
+ viewLspServers,
+ } from "../logic/view-model";
- let {
- cwd,
- canView,
- load,
- }: {
- /** The active conversation's cwd — the trigger to (re)load when it changes. */
- cwd: string | null;
- /** Whether a real conversation is focused. */
- canView: boolean;
- load: LoadLspStatus;
- } = $props();
+ let {
+ cwd,
+ canView,
+ load,
+ }: {
+ /** The active conversation's cwd — the trigger to (re)load when it changes. */
+ cwd: string | null;
+ /** Whether a real conversation is focused. */
+ canView: boolean;
+ load: LoadLspStatus;
+ } = $props();
- const badgeClass: Record<Badge, string> = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- neutral: "badge-ghost",
- };
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
- let servers = $state<readonly LspServerView[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
- let loadedCwd = $state<string | null>(null);
- let hasLoaded = $state(false);
- let summary = $state("");
+ let servers = $state<readonly LspServerView[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let loadedCwd = $state<string | null>(null);
+ let hasLoaded = $state(false);
+ let summary = $state("");
- async function refresh() {
- if (!canView) return;
- loading = true;
- error = null;
- const result = await load();
- loading = false;
- if (result === null) return;
- hasLoaded = true;
- if (result.ok) {
- servers = viewLspServers(result.servers);
- summary = summarizeServers(result.servers);
- loadedCwd = result.cwd;
- } else {
- error = result.error;
- }
- }
+ async function refresh() {
+ if (!canView) return;
+ loading = true;
+ error = null;
+ const result = await load();
+ loading = false;
+ if (result === null) return;
+ hasLoaded = true;
+ if (result.ok) {
+ servers = viewLspServers(result.servers);
+ summary = summarizeServers(result.servers);
+ loadedCwd = result.cwd;
+ } else {
+ error = result.error;
+ }
+ }
- // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET
- // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to
- // the value we already loaded for.
- $effect(() => {
- const target = cwd;
- const can = canView;
- untrack(() => {
- if (!can) return;
- if (!hasLoaded || target !== loadedCwd) void refresh();
- });
- });
+ // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET
+ // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to
+ // the value we already loaded for.
+ $effect(() => {
+ const target = cwd;
+ const can = canView;
+ untrack(() => {
+ if (!can) return;
+ if (!hasLoaded || target !== loadedCwd) void refresh();
+ });
+ });
</script>
<div class="flex flex-col gap-2">
- <div class="flex items-center justify-between gap-2">
- <span class="text-xs opacity-70">
- {#if loading}
- Resolving…
- {:else if hasLoaded && loadedCwd !== null}
- {summary}
- {:else}
- Language servers
- {/if}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={!canView || loading}
- onclick={() => refresh()}
- aria-label="Refresh language server status"
- >
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Refresh
- {/if}
- </button>
- </div>
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs opacity-70">
+ {#if loading}
+ Resolving…
+ {:else if hasLoaded && loadedCwd !== null}
+ {summary}
+ {:else}
+ Language servers
+ {/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!canView || loading}
+ onclick={() => refresh()}
+ aria-label="Refresh language server status"
+ >
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
- {#if !canView}
- <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p>
- {:else if error}
- <p class="text-xs text-error">{error}</p>
- {:else if hasLoaded && loadedCwd === null}
- <p class="text-xs opacity-60">
- Set a working directory in the Model panel to enable language servers.
- </p>
- {:else if hasLoaded && servers.length === 0 && !loading}
- <p class="text-xs opacity-60">No language servers configured for this directory.</p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each servers as server (server.id)}
- <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium">{server.name}</span>
- <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
- {#if server.busy}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {server.statusLabel}
- </span>
- </div>
- {#if server.extensionsLabel}
- <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span>
- {/if}
- <span class="truncate font-mono text-xs opacity-50" title={server.root}>{server.root}</span>
- {#if server.error}
- <span class="font-mono text-xs text-error">{server.error}</span>
- {/if}
- </li>
- {/each}
- </ul>
- {/if}
+ {#if !canView}
+ <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if hasLoaded && loadedCwd === null}
+ <p class="text-xs opacity-60">
+ Set a working directory in the Model panel to enable language servers.
+ </p>
+ {:else if hasLoaded && servers.length === 0 && !loading}
+ <p class="text-xs opacity-60">No language servers configured for this directory.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each servers as server (server.id)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium">{server.name}</span>
+ <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
+ {#if server.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {server.statusLabel}
+ </span>
+ </div>
+ {#if server.extensionsLabel}
+ <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span>
+ {/if}
+ <span class="truncate font-mono text-xs opacity-50" title={server.root}
+ >{server.root}</span
+ >
+ {#if server.error}
+ <span class="font-mono text-xs text-error">{server.error}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
</div>
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("<h1");
- expect(html).toContain("Title");
- expect(html).toContain("<strong>bold</strong>");
- });
-
- 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\n<b>x</b>\n```");
- expect(html).toContain("&lt;b&gt;");
- });
-
- it("sanitizes dangerous HTML", () => {
- const html = renderMarkdown("Hi <script>alert(1)</script> there");
- expect(html).not.toContain("<script>");
- expect(html).toContain("Hi");
- });
-
- it("balances dangling bold emphasis while streaming", () => {
- expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>");
- });
-
- it("does not balance delimiters when not streaming", () => {
- expect(renderMarkdown("a **bold")).not.toContain("<strong>");
- });
-
- it("wraps fenced code blocks with a copy button", () => {
- const html = renderMarkdown("```js\nconst x = 1;\n```");
- expect(html).toContain("code-block");
- expect(html).toContain("data-copy");
- expect(html).toContain("<pre>");
- });
-
- it("does not add a copy button to inline code", () => {
- const html = renderMarkdown("use `npm run dev` please");
- expect(html).not.toContain("data-copy");
- expect(html).toContain("<code>npm run dev</code>");
- });
-
- it("returns an empty string for empty input", () => {
- expect(renderMarkdown("")).toBe("");
- });
+ it("renders GFM markdown (headings, emphasis)", () => {
+ const html = renderMarkdown("# Title\n\nSome **bold** text.");
+ expect(html).toContain("<h1");
+ expect(html).toContain("Title");
+ expect(html).toContain("<strong>bold</strong>");
+ });
+
+ 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\n<b>x</b>\n```");
+ expect(html).toContain("&lt;b&gt;");
+ });
+
+ it("sanitizes dangerous HTML", () => {
+ const html = renderMarkdown("Hi <script>alert(1)</script> there");
+ expect(html).not.toContain("<script>");
+ expect(html).toContain("Hi");
+ });
+
+ it("balances dangling bold emphasis while streaming", () => {
+ expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>");
+ });
+
+ it("does not balance delimiters when not streaming", () => {
+ expect(renderMarkdown("a **bold")).not.toContain("<strong>");
+ });
+
+ it("wraps fenced code blocks with a copy button", () => {
+ const html = renderMarkdown("```js\nconst x = 1;\n```");
+ expect(html).toContain("code-block");
+ expect(html).toContain("data-copy");
+ expect(html).toContain("<pre>");
+ });
+
+ it("does not add a copy button to inline code", () => {
+ const html = renderMarkdown("use `npm run dev` please");
+ expect(html).not.toContain("data-copy");
+ expect(html).toContain("<code>npm run dev</code>");
+ });
+
+ it("returns an empty string for empty input", () => {
+ expect(renderMarkdown("")).toBe("");
+ });
});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
index 3a6e5a6..ad8a8bd 100644
--- a/src/features/markdown/logic/markdown.ts
+++ b/src/features/markdown/logic/markdown.ts
@@ -39,88 +39,88 @@ import { markedHighlight } from "marked-highlight";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
- bash,
- c,
- cpp,
- csharp,
- css,
- go,
- java,
- javascript,
- json,
- markdown: markdownLang,
- php,
- plaintext,
- python,
- ruby,
- rust,
- shell,
- sql,
- typescript,
- xml,
- yaml,
+ bash,
+ c,
+ cpp,
+ csharp,
+ css,
+ go,
+ java,
+ javascript,
+ json,
+ markdown: markdownLang,
+ php,
+ plaintext,
+ python,
+ ruby,
+ rust,
+ shell,
+ sql,
+ typescript,
+ xml,
+ yaml,
};
for (const [name, lang] of Object.entries(HOT_LANGUAGES)) {
- hljs.registerLanguage(name, lang);
+ hljs.registerLanguage(name, lang);
}
// Normalize common fence aliases to canonical highlight.js names.
const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
+ js: "javascript",
+ jsx: "javascript",
+ mjs: "javascript",
+ cjs: "javascript",
+ ts: "typescript",
+ tsx: "typescript",
+ py: "python",
+ py3: "python",
+ rb: "ruby",
+ sh: "bash",
+ zsh: "bash",
+ yml: "yaml",
+ "c++": "cpp",
+ cxx: "cpp",
+ "c#": "csharp",
+ cs: "csharp",
+ htm: "xml",
+ html: "xml",
+ svg: "xml",
+ md: "markdown",
+ mdx: "markdown",
+ golang: "go",
+ rs: "rust",
};
function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
+ const lower = lang.toLowerCase().trim();
+ return ALIASES[lower] ?? lower;
}
function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
}
const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- highlight(code: string, lang: string): string {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- if (!hljs.getLanguage(name)) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- { gfm: true, breaks: true },
+ markedHighlight({
+ emptyLangClass: "hljs",
+ langPrefix: "hljs language-",
+ highlight(code: string, lang: string): string {
+ if (!lang) return escapeHtml(code);
+ const name = normalizeLang(lang);
+ if (!hljs.getLanguage(name)) return escapeHtml(code);
+ try {
+ return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
+ } catch {
+ return escapeHtml(code);
+ }
+ },
+ }),
+ { gfm: true, breaks: true },
);
/**
@@ -128,14 +128,14 @@ const md = new Marked(
* partial text renders cleanly instead of flashing raw markers.
*/
function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) ?? []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) ?? []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
+ let out = src;
+ const fenceCount = (out.match(/^```/gm) ?? []).length;
+ if (fenceCount % 2 !== 0) out += "\n```";
+ const boldCount = (out.match(/\*\*/g) ?? []).length;
+ if (boldCount % 2 !== 0) out += "**";
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
+ if (inlineCode % 2 !== 0) out += "`";
+ return out;
}
// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
@@ -144,22 +144,22 @@ function closeOpenDelimiters(src: string): string {
// `data-copy` is the delegation hook the component listens for; DOMPurify keeps
// `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched.
const COPY_BUTTON =
- '<button type="button" data-copy aria-label="Copy code"' +
- ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
+ '<button type="button" data-copy aria-label="Copy code"' +
+ ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
function addCopyButtons(html: string): string {
- return html
- .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
- .replace(/<\/pre>/g, "</pre></div>");
+ return html
+ .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
+ .replace(/<\/pre>/g, "</pre></div>");
}
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
- const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
- try {
- const raw = md.parse(src) as string;
- return DOMPurify.sanitize(addCopyButtons(raw));
- } catch {
- return "";
- }
+ const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
+ try {
+ const raw = md.parse(src) as string;
+ return DOMPurify.sanitize(addCopyButtons(raw));
+ } catch {
+ return "";
+ }
}
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index b828ab9..72b892b 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,58 +1,58 @@
<script lang="ts">
- import { renderMarkdown } from "../logic/markdown";
-
- let {
- text,
- streaming = false,
- }: {
- text: string;
- /** Balance dangling delimiters while the message is still generating. */
- streaming?: boolean;
- } = $props();
-
- // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
- const html = $derived(renderMarkdown(text, { streaming }));
-
- let container: HTMLElement;
-
- // One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when `html` changes (streaming),
- // since the listener lives on the container, not the buttons. Clipboard is the
- // edge effect; absent (insecure context) → no-op.
- $effect(() => {
- const el = container;
- if (el === undefined) return;
-
- const onClick = (event: Event): void => {
- const target = event.target;
- if (!(target instanceof Element)) return;
- const button = target.closest<HTMLButtonElement>("[data-copy]");
- if (button === null) return;
-
- const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
- const clipboard = navigator.clipboard;
- if (clipboard === undefined) return;
-
- void clipboard
- .writeText(code)
- .then(() => {
- const prev = button.textContent;
- button.textContent = "Copied";
- setTimeout(() => {
- button.textContent = prev;
- }, 1200);
- })
- .catch(() => {
- // Clipboard denied — leave the button as-is.
- });
- };
-
- el.addEventListener("click", onClick);
- return () => el.removeEventListener("click", onClick);
- });
+ import { renderMarkdown } from "../logic/markdown";
+
+ let {
+ text,
+ streaming = false,
+ }: {
+ text: string;
+ /** Balance dangling delimiters while the message is still generating. */
+ streaming?: boolean;
+ } = $props();
+
+ // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
+ const html = $derived(renderMarkdown(text, { streaming }));
+
+ let container: HTMLElement;
+
+ // One delegated listener on the stable container handles every code block's
+ // copy button — including blocks re-created when `html` changes (streaming),
+ // since the listener lives on the container, not the buttons. Clipboard is the
+ // edge effect; absent (insecure context) → no-op.
+ $effect(() => {
+ const el = container;
+ if (el === undefined) return;
+
+ const onClick = (event: Event): void => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const button = target.closest<HTMLButtonElement>("[data-copy]");
+ if (button === null) return;
+
+ const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+
+ void clipboard
+ .writeText(code)
+ .then(() => {
+ const prev = button.textContent;
+ button.textContent = "Copied";
+ setTimeout(() => {
+ button.textContent = prev;
+ }, 1200);
+ })
+ .catch(() => {
+ // Clipboard denied — leave the button as-is.
+ });
+ };
+
+ el.addEventListener("click", onClick);
+ return () => el.removeEventListener("click", onClick);
+ });
</script>
<div class="markdown-body" bind:this={container}>
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html html}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html html}
</div>
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 <script>alert(1)</script> after" },
- });
+ it("strips dangerous markup", () => {
+ const { container } = render(Markdown, {
+ props: { text: "before <script>alert(1)</script> 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<HTMLElement>("[data-copy]");
- expect(button).not.toBeNull();
- if (button === null) throw new Error("expected a copy button");
+ const button = container.querySelector<HTMLElement>("[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> = {}): 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<McpStatusResult | null>;
@@ -26,18 +26,18 @@ export type LoadMcpStatus = () => Promise<McpStatusResult | null>;
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 @@
<script lang="ts">
- import { untrack } from "svelte";
- import {
- type Badge,
- type LoadMcpStatus,
- type McpServerView,
- summarizeMcpServers,
- viewMcpServers,
- } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import {
+ type Badge,
+ type LoadMcpStatus,
+ type McpServerView,
+ summarizeMcpServers,
+ viewMcpServers,
+ } from "../logic/view-model";
- let {
- cwd,
- canView,
- load,
- }: {
- /** The active conversation's cwd — the trigger to (re)load when it changes. */
- cwd: string | null;
- /** Whether a real conversation is focused. */
- canView: boolean;
- load: LoadMcpStatus;
- } = $props();
+ let {
+ cwd,
+ canView,
+ load,
+ }: {
+ /** The active conversation's cwd — the trigger to (re)load when it changes. */
+ cwd: string | null;
+ /** Whether a real conversation is focused. */
+ canView: boolean;
+ load: LoadMcpStatus;
+ } = $props();
- const badgeClass: Record<Badge, string> = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- neutral: "badge-ghost",
- };
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
- let servers = $state<readonly McpServerView[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
- let loadedCwd = $state<string | null>(null);
- let hasLoaded = $state(false);
- let summary = $state("");
+ let servers = $state<readonly McpServerView[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let loadedCwd = $state<string | null>(null);
+ let hasLoaded = $state(false);
+ let summary = $state("");
- async function refresh() {
- if (!canView) return;
- loading = true;
- error = null;
- const result = await load();
- loading = false;
- if (result === null) return;
- hasLoaded = true;
- if (result.ok) {
- servers = viewMcpServers(result.servers);
- summary = summarizeMcpServers(result.servers);
- loadedCwd = result.cwd;
- } else {
- error = result.error;
- }
- }
+ async function refresh() {
+ if (!canView) return;
+ loading = true;
+ error = null;
+ const result = await load();
+ loading = false;
+ if (result === null) return;
+ hasLoaded = true;
+ if (result.ok) {
+ servers = viewMcpServers(result.servers);
+ summary = summarizeMcpServers(result.servers);
+ loadedCwd = result.cwd;
+ } else {
+ error = result.error;
+ }
+ }
- // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET
- // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd`
- // resolves to the value we already loaded for.
- $effect(() => {
- const target = cwd;
- const can = canView;
- untrack(() => {
- if (!can) return;
- if (!hasLoaded || target !== loadedCwd) void refresh();
- });
- });
+ // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET
+ // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd`
+ // resolves to the value we already loaded for.
+ $effect(() => {
+ const target = cwd;
+ const can = canView;
+ untrack(() => {
+ if (!can) return;
+ if (!hasLoaded || target !== loadedCwd) void refresh();
+ });
+ });
</script>
<div class="flex flex-col gap-2">
- <div class="flex items-center justify-between gap-2">
- <span class="text-xs opacity-70">
- {#if loading}
- Resolving…
- {:else if hasLoaded && loadedCwd !== null}
- {summary}
- {:else}
- MCP servers
- {/if}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={!canView || loading}
- onclick={() => refresh()}
- aria-label="Refresh MCP server status"
- >
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Refresh
- {/if}
- </button>
- </div>
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs opacity-70">
+ {#if loading}
+ Resolving…
+ {:else if hasLoaded && loadedCwd !== null}
+ {summary}
+ {:else}
+ MCP servers
+ {/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!canView || loading}
+ onclick={() => refresh()}
+ aria-label="Refresh MCP server status"
+ >
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
- {#if !canView}
- <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p>
- {:else if error}
- <p class="text-xs text-error">{error}</p>
- {:else if hasLoaded && loadedCwd === null}
- <p class="text-xs opacity-60">
- Set a working directory in the Model panel to enable MCP servers.
- </p>
- {:else if hasLoaded && servers.length === 0 && !loading}
- <p class="text-xs opacity-60">No MCP servers configured for this directory.</p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each servers as server (server.id)}
- <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium font-mono">{server.id}</span>
- <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
- {#if server.busy}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {server.statusLabel}
- </span>
- </div>
- <div class="flex items-center justify-between gap-2 text-xs opacity-60">
- {#if server.configSource}
- <span class="font-mono" title="Config source">{server.configSource}</span>
- {:else}
- <span></span>
- {/if}
- <span title="Discovered tools">{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span>
- </div>
- {#if server.error}
- <span class="font-mono text-xs text-error">{server.error}</span>
- {/if}
- </li>
- {/each}
- </ul>
- {/if}
+ {#if !canView}
+ <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if hasLoaded && loadedCwd === null}
+ <p class="text-xs opacity-60">
+ Set a working directory in the Model panel to enable MCP servers.
+ </p>
+ {:else if hasLoaded && servers.length === 0 && !loading}
+ <p class="text-xs opacity-60">No MCP servers configured for this directory.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each servers as server (server.id)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium font-mono">{server.id}</span>
+ <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
+ {#if server.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {server.statusLabel}
+ </span>
+ </div>
+ <div class="flex items-center justify-between gap-2 text-xs opacity-60">
+ {#if server.configSource}
+ <span class="font-mono" title="Config source">{server.configSource}</span>
+ {:else}
+ <span></span>
+ {/if}
+ <span title="Discovered tools"
+ >{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span
+ >
+ </div>
+ {#if server.error}
+ <span class="font-mono text-xs text-error">{server.error}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
</div>
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<ChatLimitSaveResult>;
@@ -26,8 +26,8 @@ export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
/** 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 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
- import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
+ import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
- let {
- chatLimit,
- save,
- }: {
- /** The persisted chat limit (max loaded chunks per conversation). */
- chatLimit: number;
- save: SaveChatLimit;
- } = $props();
+ let {
+ chatLimit,
+ save,
+ }: {
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ chatLimit: number;
+ save: SaveChatLimit;
+ } = $props();
- // Seed from the prop; the $effect below re-seeds on external changes (a live
- // apply from elsewhere) but only while the field is untouched, so an in-flight
- // change can't clobber what the user typed.
- let value = $state("");
- let lastSeed = $state("");
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
- let savedValue = $state<number | null>(null);
+ // Seed from the prop; the $effect below re-seeds on external changes (a live
+ // apply from elsewhere) but only while the field is untouched, so an in-flight
+ // change can't clobber what the user typed.
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let savedValue = $state<number | null>(null);
- $effect(() => {
- const incoming = String(chatLimit);
- untrack(() => {
- if (value === lastSeed) value = incoming;
- lastSeed = incoming;
- });
- });
+ $effect(() => {
+ const incoming = String(chatLimit);
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
- const dirty = $derived(chatLimitChanged(value, chatLimit));
+ const dirty = $derived(chatLimitChanged(value, chatLimit));
- async function handleSave() {
- if (saving || !dirty) return;
- const parsed = parseChatLimit(value);
- if (!parsed.ok) {
- error = parsed.error;
- return;
- }
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(parsed.value);
- saving = false;
- if (result.ok) {
- justSaved = true;
- savedValue = result.chatLimit;
- // Reflect the clamped / persisted value back into the input immediately
- // (the prop will also re-assert it via the effect above).
- value = String(result.chatLimit);
- lastSeed = value;
- } else {
- error = result.error;
- }
- }
+ async function handleSave() {
+ if (saving || !dirty) return;
+ const parsed = parseChatLimit(value);
+ if (!parsed.ok) {
+ error = parsed.error;
+ return;
+ }
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(parsed.value);
+ saving = false;
+ if (result.ok) {
+ justSaved = true;
+ savedValue = result.chatLimit;
+ // Reflect the clamped / persisted value back into the input immediately
+ // (the prop will also re-assert it via the effect above).
+ value = String(result.chatLimit);
+ lastSeed = value;
+ } else {
+ error = result.error;
+ }
+ }
- function onInput() {
- justSaved = false;
- error = null;
- }
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
- <div class="flex items-center gap-2">
- <input
- type="text"
- inputmode="numeric"
- class="input input-bordered input-sm w-full font-mono text-xs"
- placeholder={String(chatLimit)}
- bind:value
- disabled={saving}
- oninput={onInput}
- onkeydown={(e) => {
- if (e.key === "Enter") handleSave();
- }}
- aria-label="Chat limit"
- />
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={saving || !dirty}
- onclick={handleSave}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved && !dirty}
- <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
- {:else}
- <p class="text-xs opacity-50">
- 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.
- </p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={String(chatLimit)}
+ bind:value
+ disabled={saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Chat limit"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ 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.
+ </p>
+ {/if}
</div>
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<ChatLimitSaveResult>;
+ saves: number[];
+ last: number | null;
+ impl: (value: number) => Promise<ChatLimitSaveResult>;
} {
- 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 @@
<script lang="ts">
- // Thin affordance: a floating "scroll to bottom" button shown while the reader
- // has scrolled up. Holds no logic — `show` and `onResume` come from the
- // smart-scroll controller.
- let {
- show,
- onResume,
- }: {
- show: boolean;
- onResume: () => void;
- } = $props();
+ // Thin affordance: a floating "scroll to bottom" button shown while the reader
+ // has scrolled up. Holds no logic — `show` and `onResume` come from the
+ // smart-scroll controller.
+ let {
+ show,
+ onResume,
+ }: {
+ show: boolean;
+ onResume: () => void;
+ } = $props();
</script>
<button
- type="button"
- class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
- class:opacity-0={!show}
- class:pointer-events-none={!show}
- class:opacity-100={show}
- onclick={onResume}
- aria-label="Scroll to bottom"
- aria-hidden={!show}
- tabindex={show ? 0 : -1}
+ type="button"
+ class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
+ class:opacity-0={!show}
+ class:pointer-events-none={!show}
+ class:opacity-100={show}
+ onclick={onResume}
+ aria-label="Scroll to bottom"
+ aria-hidden={!show}
+ tabindex={show ? 0 : -1}
>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2.5"
- class="size-4"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
- </svg>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
+ </svg>
</button>
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<string, Set<EventListener>>();
- 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<string, Set<EventListener>>();
+ 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<string, unknown>;
- 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<string, unknown>;
+ 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<string, unknown>;
- 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<string, unknown>;
+ 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<FieldView, { action: unknown }>,
- value?: unknown,
+ surfaceId: string,
+ field: Extract<FieldView, { action: unknown }>,
+ 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<string, unknown>;
-
- 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<string, unknown>;
+
+ 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<string>(["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<string, unknown>;
- 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<string, unknown>;
+ 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<string, unknown>;
- 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<string, unknown>;
+ 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<FieldView, StatFieldView> };
+ | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
+ | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };
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 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { ButtonFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { ButtonFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleClick() {
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- });
- }
+ function handleClick() {
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ });
+ }
</script>
<button onclick={handleClick}>{field.label}</button>
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 @@
<script lang="ts">
- import { parseMessageQueuePayload } from "../logic/message-queue";
+ import { parseMessageQueuePayload } from "../logic/message-queue";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- // Parse defensively; an unparseable payload yields null → render nothing
- // (graceful skip, per the custom-field contract).
- const data = $derived(parseMessageQueuePayload(payload));
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseMessageQueuePayload(payload));
</script>
{#if data !== null && data.messages.length > 0}
- <ul class="flex flex-col gap-1 text-sm">
- {#each data.messages as msg (msg.id)}
- <li class="rounded-box bg-base-200 px-3 py-2">
- <p class="whitespace-pre-wrap">{msg.text}</p>
- <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
- {new Date(msg.queuedAt).toLocaleTimeString()}
- </time>
- </li>
- {/each}
- </ul>
+ <ul class="flex flex-col gap-1 text-sm">
+ {#each data.messages as msg (msg.id)}
+ <li class="rounded-box bg-base-200 px-3 py-2">
+ <p class="whitespace-pre-wrap">{msg.text}</p>
+ <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
+ {new Date(msg.queuedAt).toLocaleTimeString()}
+ </time>
+ </li>
+ {/each}
+ </ul>
{/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 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { NumberFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { NumberFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric
- // input (the backend also floors/validates); send the new number as payload.
- function commit(event: Event) {
- const target = event.target as HTMLInputElement;
- const next = target.valueAsNumber;
- if (Number.isNaN(next)) return;
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: next,
- });
- }
+ // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric
+ // input (the backend also floors/validates); send the new number as payload.
+ function commit(event: Event) {
+ const target = event.target as HTMLInputElement;
+ const next = target.valueAsNumber;
+ if (Number.isNaN(next)) return;
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: next,
+ });
+ }
</script>
<label class="flex items-center justify-between gap-2 text-sm">
- <span>{field.label}</span>
- <span class="flex items-center gap-1">
- <input
- type="number"
- class="input input-bordered input-sm w-24"
- value={field.value}
- min={field.min}
- max={field.max}
- step={field.step}
- onchange={commit}
- />
- {#if field.unit}
- <span class="opacity-60">{field.unit}</span>
- {/if}
- </span>
+ <span>{field.label}</span>
+ <span class="flex items-center gap-1">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ value={field.value}
+ min={field.min}
+ max={field.max}
+ step={field.step}
+ onchange={commit}
+ />
+ {#if field.unit}
+ <span class="opacity-60">{field.unit}</span>
+ {/if}
+ </span>
</label>
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 @@
<script lang="ts">
- import type { ProgressFieldView } from "../logic/types";
+ import type { ProgressFieldView } from "../logic/types";
- let { field }: { field: ProgressFieldView } = $props();
+ let { field }: { field: ProgressFieldView } = $props();
- const percent = $derived(Math.round(field.value * 100));
+ const percent = $derived(Math.round(field.value * 100));
</script>
<div>
- <span>{field.label}</span>
- <progress max="100" value={percent}>{percent}%</progress>
- <span>{percent}%</span>
+ <span>{field.label}</span>
+ <progress max="100" value={percent}>{percent}%</progress>
+ <span>{percent}%</span>
</div>
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 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { SelectorFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { SelectorFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleChange(event: Event) {
- const target = event.target as HTMLSelectElement;
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: target.value,
- });
- }
+ function handleChange(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: target.value,
+ });
+ }
</script>
<label>
- {field.label}
- <select onchange={handleChange}>
- {#each field.options as option (option.value)}
- <option value={option.value} selected={option.value === field.value}>
- {option.label}
- </option>
- {/each}
- </select>
+ {field.label}
+ <select onchange={handleChange}>
+ {#each field.options as option (option.value)}
+ <option value={option.value} selected={option.value === field.value}>
+ {option.label}
+ </option>
+ {/each}
+ </select>
</label>
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 @@
<script lang="ts">
- import type { StatFieldView } from "../logic/types";
+ import type { StatFieldView } from "../logic/types";
- // Renders a run of stat fields as one aligned label/value table. Headerless:
- // the column semantics aren't known generically, but the two-column layout
- // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
- let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
+ // Renders a run of stat fields as one aligned label/value table. Headerless:
+ // the column semantics aren't known generically, but the two-column layout
+ // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
+ let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
</script>
<div class="overflow-x-auto">
- <table class="table table-sm">
- <tbody>
- {#each stats as stat, i (i)}
- <tr>
- <th class="font-medium">{stat.label}</th>
- <td class="text-right tabular-nums">{stat.value}</td>
- </tr>
- {/each}
- </tbody>
- </table>
+ <table class="table table-sm">
+ <tbody>
+ {#each stats as stat, i (i)}
+ <tr>
+ <th class="font-medium">{stat.label}</th>
+ <td class="text-right tabular-nums">{stat.value}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
</div>
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 @@
<script lang="ts">
- import Table from "../../../components/Table.svelte";
- import { parseTablePayload } from "../logic/table";
+ import Table from "../../../components/Table.svelte";
+ import { parseTablePayload } from "../logic/table";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- // Parse defensively; an unparseable payload yields null → render nothing
- // (graceful skip, per the custom-field contract).
- const data = $derived(parseTablePayload(payload));
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseTablePayload(payload));
</script>
{#if data !== null}
- <Table columns={data.columns} rows={data.rows} />
+ <Table columns={data.columns} rows={data.rows} />
{/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 @@
<script lang="ts">
- import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { groupRenderFields, planSurface } from "../logic/plan";
- import Button from "./Button.svelte";
- import MessageQueueList from "./MessageQueueList.svelte";
- import Number from "./Number.svelte";
- import Progress from "./Progress.svelte";
- import Selector from "./Selector.svelte";
- import StatTable from "./StatTable.svelte";
- import SurfaceTable from "./SurfaceTable.svelte";
- import TodoList from "./TodoList.svelte";
- import Toggle from "./Toggle.svelte";
+ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+ import { groupRenderFields, planSurface } from "../logic/plan";
+ import Button from "./Button.svelte";
+ import MessageQueueList from "./MessageQueueList.svelte";
+ import Number from "./Number.svelte";
+ import Progress from "./Progress.svelte";
+ import Selector from "./Selector.svelte";
+ import StatTable from "./StatTable.svelte";
+ import SurfaceTable from "./SurfaceTable.svelte";
+ import TodoList from "./TodoList.svelte";
+ import Toggle from "./Toggle.svelte";
- let {
- spec,
- onInvoke,
- }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
+ let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
- const plan = $derived(planSurface(spec));
- // Consecutive stats render together as one aligned table; everything else is
- // a standalone widget. Grouping keys on field KIND only — never the surface id.
- const groups = $derived(groupRenderFields(plan.fields));
+ const plan = $derived(planSurface(spec));
+ // Consecutive stats render together as one aligned table; everything else is
+ // a standalone widget. Grouping keys on field KIND only — never the surface id.
+ const groups = $derived(groupRenderFields(plan.fields));
</script>
<article>
- <h2>{spec.title}</h2>
- {#each groups as group, i (i)}
- {#if group.type === "stats"}
- <StatTable stats={group.stats} />
- {:else if group.field.kind === "toggle"}
- <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "progress"}
- <Progress field={group.field} />
- {:else if group.field.kind === "selector"}
- <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "number"}
- <Number field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "button"}
- <Button field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "custom"}
- <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
- unknown ids gracefully render nothing. -->
- {#if group.field.rendererId === "table"}
- <SurfaceTable payload={group.field.payload} />
- {:else if group.field.rendererId === "message-queue"}
- <MessageQueueList payload={group.field.payload} />
- {:else if group.field.rendererId === "todo"}
- <TodoList payload={group.field.payload} />
- {/if}
- {/if}
- {/each}
+ <h2>{spec.title}</h2>
+ {#each groups as group, i (i)}
+ {#if group.type === "stats"}
+ <StatTable stats={group.stats} />
+ {:else if group.field.kind === "toggle"}
+ <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "progress"}
+ <Progress field={group.field} />
+ {:else if group.field.kind === "selector"}
+ <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "number"}
+ <Number field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "button"}
+ <Button field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "custom"}
+ <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
+ unknown ids gracefully render nothing. -->
+ {#if group.field.rendererId === "table"}
+ <SurfaceTable payload={group.field.payload} />
+ {:else if group.field.rendererId === "message-queue"}
+ <MessageQueueList payload={group.field.payload} />
+ {:else if group.field.rendererId === "todo"}
+ <TodoList payload={group.field.payload} />
+ {/if}
+ {/if}
+ {/each}
</article>
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 @@
<script lang="ts">
- import { parseTodoPayload } from "../logic/todo";
+ import { parseTodoPayload } from "../logic/todo";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- const data = $derived(parseTodoPayload(payload));
+ const data = $derived(parseTodoPayload(payload));
</script>
{#if data !== null && data.todos.length > 0}
- <ul class="flex flex-col gap-1">
- {#each data.todos as todo, i (i)}
- <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm">
- <!-- Status indicator -->
- <span class="mt-0.5 shrink-0">
- {#if todo.status === "in_progress"}
- <span class="block h-4 w-4 rounded-full bg-primary"></span>
- {:else if todo.status === "completed"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-success"
- >
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {:else if todo.status === "cancelled"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-base-content/40"
- >
- <line x1="18" y1="6" x2="6" y2="18"></line>
- <line x1="6" y1="6" x2="18" y2="18"></line>
- </svg>
- {:else}
- <!-- pending: empty circle -->
- <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span>
- {/if}
- </span>
+ <ul class="flex flex-col gap-1">
+ {#each data.todos as todo, i (i)}
+ <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm">
+ <!-- Status indicator -->
+ <span class="mt-0.5 shrink-0">
+ {#if todo.status === "in_progress"}
+ <span class="block h-4 w-4 rounded-full bg-primary"></span>
+ {:else if todo.status === "completed"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {:else if todo.status === "cancelled"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-base-content/40"
+ >
+ <line x1="18" y1="6" x2="6" y2="18"></line>
+ <line x1="6" y1="6" x2="18" y2="18"></line>
+ </svg>
+ {:else}
+ <!-- pending: empty circle -->
+ <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span>
+ {/if}
+ </span>
- <!-- Content -->
- <span
- class:flex-1={true}
- class:line-through={todo.status === "completed" || todo.status === "cancelled"}
- class:opacity-50={todo.status === "completed" || todo.status === "cancelled"}
- >
- {todo.content}
- </span>
- </li>
- {/each}
- </ul>
+ <!-- Content -->
+ <span
+ class:flex-1={true}
+ class:line-through={todo.status === "completed" || todo.status === "cancelled"}
+ class:opacity-50={todo.status === "completed" || todo.status === "cancelled"}
+ >
+ {todo.content}
+ </span>
+ </li>
+ {/each}
+ </ul>
{/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 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { ToggleFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { ToggleFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleChange() {
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: !field.value,
- });
- }
+ function handleChange() {
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: !field.value,
+ });
+ }
</script>
<label>
- <input type="checkbox" checked={field.value} onchange={handleChange} />
- {field.label}
+ <input type="checkbox" checked={field.value} onchange={handleChange} />
+ {field.label}
</label>
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<SystemPromptLoadResult>;
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<SystemPromptSaveResult>;
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<SystemPromptVariablesResult>;
@@ -35,24 +35,24 @@ export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResul
/** Build the literal placeholder `[type:name]` for a variable. */
export function buildTag(type: string, name: string): string {
- return `[${type}:${name}]`;
+ return `[${type}:${name}]`;
}
/** Build the literal placeholder `[if type:name]` for a conditional block. */
export function buildIfTag(type: string, name: string): string {
- return `[if ${type}:${name}]`;
+ return `[if ${type}:${name}]`;
}
/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */
export function buildIfNotTag(type: string, name: string): string {
- return `[if !${type}:${name}]`;
+ return `[if !${type}:${name}]`;
}
export interface Insertion {
- /** Template text after insertion. */
- template: string;
- /** New cursor position (caret index) after insertion. */
- cursor: number;
+ /** Template text after insertion. */
+ template: string;
+ /** New cursor position (caret index) after insertion. */
+ cursor: number;
}
/**
@@ -61,24 +61,24 @@ export interface Insertion {
* the caller can restore the caret after the inserted tag.
*/
export function insertTag(
- template: string,
- tag: string,
- selectionStart: number,
- selectionEnd: number,
+ template: string,
+ tag: string,
+ selectionStart: number,
+ selectionEnd: number,
): Insertion {
- const before = template.slice(0, selectionStart);
- const after = template.slice(selectionEnd);
- const next = before + tag + after;
- return { template: next, cursor: selectionStart + tag.length };
+ const before = template.slice(0, selectionStart);
+ const after = template.slice(selectionEnd);
+ const next = before + tag + after;
+ return { template: next, cursor: selectionStart + tag.length };
}
// ── Variable grouping ─────────────────────────────────────────────────────────
export interface VariableGroup {
- /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
- readonly type: string;
- /** Variables of this type. */
- readonly variables: readonly SystemPromptVariable[];
+ /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
+ readonly type: string;
+ /** Variables of this type. */
+ readonly variables: readonly SystemPromptVariable[];
}
/**
@@ -86,21 +86,21 @@ export interface VariableGroup {
* group and the order of first-appearing types.
*/
export function groupVariables(
- variables: readonly SystemPromptVariable[],
+ variables: readonly SystemPromptVariable[],
): readonly VariableGroup[] {
- const order: string[] = [];
- const map = new Map<string, SystemPromptVariable[]>();
- 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<string, SystemPromptVariable[]>();
+ 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:<path>`). */
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 @@
<script lang="ts">
- import type { SystemPromptVariable } from "@dispatch/transport-contract";
- import { tick, untrack } from "svelte";
- import {
- buildTag,
- groupVariables,
- insertTag,
- isDynamicVariable,
- type LoadSystemPrompt,
- type LoadSystemPromptVariables,
- type SaveSystemPrompt,
- } from "../logic/view-model";
-
- let {
- loadPrompt,
- savePrompt,
- loadVariables,
- onClose,
- }: {
- loadPrompt: LoadSystemPrompt;
- savePrompt: SaveSystemPrompt;
- loadVariables: LoadSystemPromptVariables;
- onClose: () => void;
- } = $props();
-
- let value = $state("");
- let loadedTemplate = $state("");
- let loading = $state(false);
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
- let variables = $state<readonly SystemPromptVariable[]>([]);
- let filePath = $state("");
- let textarea: HTMLTextAreaElement | null = null;
-
- const groups = $derived(groupVariables(variables));
- const hasChanges = $derived(value !== loadedTemplate);
-
- async function load() {
- untrack(() => {
- loading = true;
- error = null;
- });
-
- const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
-
- loading = false;
-
- if (!templateResult.ok || !variablesResult.ok) {
- const parts: string[] = [];
- if (!templateResult.ok) parts.push(templateResult.error);
- if (!variablesResult.ok) parts.push(variablesResult.error);
- error = parts.join("; ");
- return;
- }
-
- value = templateResult.template;
- loadedTemplate = templateResult.template;
- variables = variablesResult.variables;
- }
-
- async function save() {
- if (saving || loading) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await savePrompt(value);
- saving = false;
- if (!result.ok) {
- error = result.error;
- return;
- }
- loadedTemplate = result.template;
- value = result.template;
- justSaved = true;
- }
-
- function reset() {
- value = loadedTemplate;
- error = null;
- justSaved = false;
- }
-
- async function insertTagAtCursor(tag: string) {
- if (textarea === null) return;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const insertion = insertTag(value, tag, start, end);
- value = insertion.template;
- await tick();
- textarea.focus();
- textarea.setSelectionRange(insertion.cursor, insertion.cursor);
- }
-
- async function insertFileVariable(type: string) {
- const path = filePath.trim();
- if (path.length === 0) return;
- await insertTagAtCursor(buildTag(type, path));
- filePath = "";
- }
-
- function onKeydown(e: KeyboardEvent) {
- if (e.key === "Escape") onClose();
- }
-
- // Load on mount once.
- $effect(() => {
- void load();
- });
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPrompt,
+ type LoadSystemPromptVariables,
+ type SaveSystemPrompt,
+ } from "../logic/view-model";
+
+ let {
+ loadPrompt,
+ savePrompt,
+ loadVariables,
+ onClose,
+ }: {
+ loadPrompt: LoadSystemPrompt;
+ savePrompt: SaveSystemPrompt;
+ loadVariables: LoadSystemPromptVariables;
+ onClose: () => void;
+ } = $props();
+
+ let value = $state("");
+ let loadedTemplate = $state("");
+ let loading = $state(false);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let filePath = $state("");
+ let textarea: HTMLTextAreaElement | null = null;
+
+ const groups = $derived(groupVariables(variables));
+ const hasChanges = $derived(value !== loadedTemplate);
+
+ async function load() {
+ untrack(() => {
+ loading = true;
+ error = null;
+ });
+
+ const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
+
+ loading = false;
+
+ if (!templateResult.ok || !variablesResult.ok) {
+ const parts: string[] = [];
+ if (!templateResult.ok) parts.push(templateResult.error);
+ if (!variablesResult.ok) parts.push(variablesResult.error);
+ error = parts.join("; ");
+ return;
+ }
+
+ value = templateResult.template;
+ loadedTemplate = templateResult.template;
+ variables = variablesResult.variables;
+ }
+
+ async function save() {
+ if (saving || loading) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await savePrompt(value);
+ saving = false;
+ if (!result.ok) {
+ error = result.error;
+ return;
+ }
+ loadedTemplate = result.template;
+ value = result.template;
+ justSaved = true;
+ }
+
+ function reset() {
+ value = loadedTemplate;
+ error = null;
+ justSaved = false;
+ }
+
+ async function insertTagAtCursor(tag: string) {
+ if (textarea === null) return;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const insertion = insertTag(value, tag, start, end);
+ value = insertion.template;
+ await tick();
+ textarea.focus();
+ textarea.setSelectionRange(insertion.cursor, insertion.cursor);
+ }
+
+ async function insertFileVariable(type: string) {
+ const path = filePath.trim();
+ if (path.length === 0) return;
+ await insertTagAtCursor(buildTag(type, path));
+ filePath = "";
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load on mount once.
+ $effect(() => {
+ void load();
+ });
</script>
<svelte:window onkeydown={onKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
- role="dialog"
- aria-modal="true"
- aria-label="System prompt builder"
- tabindex="-1"
- onclick={onClose}
- onkeydown={onKeydown}
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="System prompt builder"
+ tabindex="-1"
+ onclick={onClose}
+ onkeydown={onKeydown}
>
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
- <div
- class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
- onclick={(e) => e.stopPropagation()}
- >
- <!-- Header -->
- <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
- <div class="flex items-center gap-2">
- <h2 class="text-sm font-semibold">System Prompt</h2>
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
- <button
- type="button"
- class="btn btn-ghost btn-sm btn-square"
- onclick={onClose}
- aria-label="Close system prompt builder"
- >
- ✕
- </button>
- </div>
-
- <!-- Body: half editor / half variables -->
- <div class="flex min-h-0 flex-1">
- <!-- Left: template editor -->
- <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
- <textarea
- bind:this={textarea}
- bind:value
- class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
- placeholder={loading ? "Loading template..." : "Edit the global system prompt template..."}
- disabled={loading}
- aria-label="System prompt template"
- ></textarea>
-
- <div class="flex shrink-0 items-center gap-2">
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={loading || saving || !hasChanges}
- onclick={save}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- disabled={loading || !hasChanges}
- onclick={reset}
- >
- Reset
- </button>
- {#if justSaved && !hasChanges}
- <span class="text-xs text-success">Saved.</span>
- {:else if hasChanges}
- <span class="text-xs opacity-60">Unsaved changes</span>
- {/if}
- </div>
-
- {#if error}
- <p class="shrink-0 text-xs text-error">{error}</p>
- {/if}
- </div>
-
- <!-- Right: variable palette -->
- <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
- <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
- {#if groups.length === 0 && !loading}
- <p class="text-xs opacity-60">No variables available.</p>
- {/if}
- <div class="flex flex-col gap-3">
- {#each groups as group (group.type)}
- <div class="rounded-box bg-base-200 p-3">
- <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
- <div class="mt-2 flex flex-wrap gap-1">
- {#each group.variables as variable (variable.type + variable.name)}
- {#if isDynamicVariable(variable)}
- <div class="flex items-center gap-1">
- <input
- type="text"
- class="input input-bordered input-xs w-32 font-mono"
- bind:value={filePath}
- placeholder={variable.name}
- onkeydown={(e) => {
- if (e.key === "Enter") void insertFileVariable(variable.type);
- }}
- />
- <button
- type="button"
- class="btn btn-xs"
- onclick={() => void insertFileVariable(variable.type)}
- >
- Insert
- </button>
- </div>
- {:else}
- <button
- type="button"
- class="btn btn-xs"
- title={variable.description}
- onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
- >
- {variable.name}
- </button>
- {/if}
- {/each}
- </div>
- </div>
- {/each}
- </div>
- </div>
- </div>
- </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div
+ class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <!-- Header -->
+ <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <h2 class="text-sm font-semibold">System Prompt</h2>
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ onclick={onClose}
+ aria-label="Close system prompt builder"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- Left: template editor -->
+ <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
+ <textarea
+ bind:this={textarea}
+ bind:value
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder={loading
+ ? "Loading template..."
+ : "Edit the global system prompt template..."}
+ disabled={loading}
+ aria-label="System prompt template"></textarea>
+
+ <div class="flex shrink-0 items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={loading || saving || !hasChanges}
+ onclick={save}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ disabled={loading || !hasChanges}
+ onclick={reset}
+ >
+ Reset
+ </button>
+ {#if justSaved && !hasChanges}
+ <span class="text-xs text-success">Saved.</span>
+ {:else if hasChanges}
+ <span class="text-xs opacity-60">Unsaved changes</span>
+ {/if}
+ </div>
+
+ {#if error}
+ <p class="shrink-0 text-xs text-error">{error}</p>
+ {/if}
+ </div>
+
+ <!-- Right: variable palette -->
+ <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
+ <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
+ {#if groups.length === 0 && !loading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {/if}
+ <div class="flex flex-col gap-3">
+ {#each groups as group (group.type)}
+ <div class="rounded-box bg-base-200 p-3">
+ <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
+ <div class="mt-2 flex flex-wrap gap-1">
+ {#each group.variables as variable (variable.type + variable.name)}
+ {#if isDynamicVariable(variable)}
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ bind:value={filePath}
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") void insertFileVariable(variable.type);
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={() => void insertFileVariable(variable.type)}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ </div>
</div>
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<TabsState>(storage.load() ?? initialState());
+ let state = $state<TabsState>(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 @@
<script lang="ts">
- import type { Tab } from "../tabs";
- import { isStuckToEnd, shortHandle } from "../tabs";
+ import type { Tab } from "../tabs";
+ import { isStuckToEnd, shortHandle } from "../tabs";
- let {
- tabs,
- activeConversationId,
- statusFor,
- onSelect,
- onClose,
- onNewDraft,
- onRename,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- /** Returns the conversation's lifecycle status, or undefined when unknown. */
- statusFor?: (conversationId: string) => string | undefined;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- onRename?: (conversationId: string, title: string) => void;
- } = $props();
+ let {
+ tabs,
+ activeConversationId,
+ statusFor,
+ onSelect,
+ onClose,
+ onNewDraft,
+ onRename,
+ }: {
+ tabs: readonly Tab[];
+ activeConversationId: string | null;
+ /** Returns the conversation's lifecycle status, or undefined when unknown. */
+ statusFor?: (conversationId: string) => string | undefined;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onNewDraft: () => void;
+ onRename?: (conversationId: string, title: string) => void;
+ } = $props();
- // The new-chat button is `position: sticky; right: 0`. It floats over the tabs
- // only while the strip overflows and isn't scrolled fully right; we square its
- // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) +
- // DOM-measurement at the edge here.
- let scrollEl = $state<HTMLDivElement>();
- let stuck = $state(false);
+ // The new-chat button is `position: sticky; right: 0`. It floats over the tabs
+ // only while the strip overflows and isn't scrolled fully right; we square its
+ // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) +
+ // DOM-measurement at the edge here.
+ let scrollEl = $state<HTMLDivElement>();
+ let stuck = $state(false);
- // Git-style short handle (shortest unique prefix) per open tab — the visible
- // "tab ID". Derived from the set of open conversation ids; pure helper.
- const handles = $derived.by(() => {
- const ids = tabs.map((t) => t.conversationId);
- const map = new Map<string, string>();
- for (const id of ids) map.set(id, shortHandle(id, ids));
- return map;
- });
+ // Git-style short handle (shortest unique prefix) per open tab — the visible
+ // "tab ID". Derived from the set of open conversation ids; pure helper.
+ const handles = $derived.by(() => {
+ const ids = tabs.map((t) => t.conversationId);
+ const map = new Map<string, string>();
+ for (const id of ids) map.set(id, shortHandle(id, ids));
+ return map;
+ });
- function recompute(): void {
- const el = scrollEl;
- if (el === undefined) {
- stuck = false;
- return;
- }
- stuck = isStuckToEnd({
- scrollLeft: el.scrollLeft,
- clientWidth: el.clientWidth,
- scrollWidth: el.scrollWidth,
- });
- }
+ function recompute(): void {
+ const el = scrollEl;
+ if (el === undefined) {
+ stuck = false;
+ return;
+ }
+ stuck = isStuckToEnd({
+ scrollLeft: el.scrollLeft,
+ clientWidth: el.clientWidth,
+ scrollWidth: el.scrollWidth,
+ });
+ }
- $effect(() => {
- const el = scrollEl;
- if (el === undefined) return;
- // Re-evaluate when the tab set changes (overflow may appear/disappear).
- void tabs;
- recompute();
+ $effect(() => {
+ const el = scrollEl;
+ if (el === undefined) return;
+ // Re-evaluate when the tab set changes (overflow may appear/disappear).
+ void tabs;
+ recompute();
- el.addEventListener("scroll", recompute, { passive: true });
- const ro =
- typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined;
- ro?.observe(el);
+ el.addEventListener("scroll", recompute, { passive: true });
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined;
+ ro?.observe(el);
- return () => {
- el.removeEventListener("scroll", recompute);
- ro?.disconnect();
- };
- });
- // Inline rename: double-click a tab's title to edit, Enter/blur to save.
- let editingId = $state<string | null>(null);
- let editValue = $state("");
- let editEl = $state<HTMLInputElement>();
+ return () => {
+ el.removeEventListener("scroll", recompute);
+ ro?.disconnect();
+ };
+ });
+ // Inline rename: double-click a tab's title to edit, Enter/blur to save.
+ let editingId = $state<string | null>(null);
+ let editValue = $state("");
+ let editEl = $state<HTMLInputElement>();
- function startRename(tab: Tab): void {
- if (onRename === undefined) return;
- editingId = tab.conversationId;
- editValue = tab.title;
- // Focus the input after it renders.
- queueMicrotask(() => editEl?.focus());
- }
+ function startRename(tab: Tab): void {
+ if (onRename === undefined) return;
+ editingId = tab.conversationId;
+ editValue = tab.title;
+ // Focus the input after it renders.
+ queueMicrotask(() => editEl?.focus());
+ }
- function commitRename(): void {
- const id = editingId;
- if (id !== null && onRename !== undefined) {
- const trimmed = editValue.trim();
- if (trimmed.length > 0) onRename(id, trimmed);
- }
- editingId = null;
- }
+ function commitRename(): void {
+ const id = editingId;
+ if (id !== null && onRename !== undefined) {
+ const trimmed = editValue.trim();
+ if (trimmed.length > 0) onRename(id, trimmed);
+ }
+ editingId = null;
+ }
- function cancelRename(): void {
- editingId = null;
- }
+ function cancelRename(): void {
+ editingId = null;
+ }
</script>
<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto">
- <div class="tabs tabs-lift min-w-max">
- {#each tabs as tab (tab.conversationId)}
- <div
- class="tab flex w-48 shrink-0 items-center gap-1.5"
- class:tab-active={tab.conversationId === activeConversationId}
- role="tab"
- tabindex="0"
- onclick={() => onSelect(tab.conversationId)}
- onkeydown={(e) => {
- if (e.key === "Enter") onSelect(tab.conversationId);
- }}
- >
- <span
- class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
- title="Tab ID"
- >
- {handles.get(tab.conversationId) ?? tab.conversationId}
- </span>
- {#if editingId === tab.conversationId}
- <input
- bind:this={editEl}
- bind:value={editValue}
- class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
- }}
- onblur={commitRename}
- />
- {:else}
- <span
- class="min-w-0 flex-1 cursor-pointer truncate text-left"
- role="button"
- tabindex="-1"
- title={tab.title}
- ondblclick={(e) => {
- e.stopPropagation();
- startRename(tab);
- }}
- >
- {tab.title}
- </span>
- {/if}
- {#if statusFor?.(tab.conversationId) === "active"}
- <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
- {/if}
- <button
- class="btn btn-ghost btn-xs shrink-0"
- aria-label="Close tab"
- onclick={(e) => {
- e.stopPropagation();
- onClose(tab.conversationId);
- }}
- >
- &times;
- </button>
- </div>
- {/each}
- <button
- class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck
- ? '!rounded-se-none !rounded-ee-none'
- : ''}"
- class:tab-active={activeConversationId === null}
- aria-label="New chat"
- onclick={() => onNewDraft()}
- >
- {#if activeConversationId === null}
- <span class="max-w-[120px] truncate">New Chat</span>
- <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span>
- {:else}
- +
- {/if}
- </button>
- </div>
+ <div class="tabs tabs-lift min-w-max">
+ {#each tabs as tab (tab.conversationId)}
+ <div
+ class="tab flex w-48 shrink-0 items-center gap-1.5"
+ class:tab-active={tab.conversationId === activeConversationId}
+ role="tab"
+ tabindex="0"
+ onclick={() => onSelect(tab.conversationId)}
+ onkeydown={(e) => {
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ }}
+ >
+ <span
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
+ title="Tab ID"
+ >
+ {handles.get(tab.conversationId) ?? tab.conversationId}
+ </span>
+ {#if editingId === tab.conversationId}
+ <input
+ bind:this={editEl}
+ bind:value={editValue}
+ class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
+ onclick={(e) => e.stopPropagation()}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelRename();
+ }
+ }}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="min-w-0 flex-1 cursor-pointer truncate text-left"
+ role="button"
+ tabindex="-1"
+ title={tab.title}
+ ondblclick={(e) => {
+ e.stopPropagation();
+ startRename(tab);
+ }}
+ >
+ {tab.title}
+ </span>
+ {/if}
+ {#if statusFor?.(tab.conversationId) === "active"}
+ <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
+ {/if}
+ <button
+ class="btn btn-ghost btn-xs shrink-0"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {/each}
+ <button
+ class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck
+ ? '!rounded-se-none !rounded-ee-none'
+ : ''}"
+ class:tab-active={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ {#if activeConversationId === null}
+ <span class="max-w-[120px] truncate">New Chat</span>
+ <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span>
+ {:else}
+ +
+ {/if}
+ </button>
+ </div>
</div>
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 @@
<script lang="ts">
- import { type Snippet, untrack } from "svelte";
- import {
- addPanel,
- initialPanels,
- type PanelsState,
- removePanel,
- selectKind,
- } from "../logic/panels";
+ import { type Snippet, untrack } from "svelte";
+ import {
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ } from "../logic/panels";
- interface ViewKind {
- readonly id: string;
- readonly label: string;
- }
+ interface ViewKind {
+ readonly id: string;
+ readonly label: string;
+ }
- let {
- kinds,
- content,
- initial,
- onChange,
- }: {
- /** The view kinds offered in every panel's dropdown. */
- kinds: readonly ViewKind[];
- /** Renders a panel body for the given (non-null) view-kind id. */
- content: Snippet<[string]>;
- /** Optional seed of panel kinds; defaults to one panel of the first kind. */
- initial?: readonly (string | null)[];
- /** Called whenever the panel layout changes (add/remove/select). */
- onChange?: (kinds: readonly (string | null)[]) => void;
- } = $props();
+ let {
+ kinds,
+ content,
+ initial,
+ onChange,
+ }: {
+ /** The view kinds offered in every panel's dropdown. */
+ kinds: readonly ViewKind[];
+ /** Renders a panel body for the given (non-null) view-kind id. */
+ content: Snippet<[string]>;
+ /** Optional seed of panel kinds; defaults to one panel of the first kind. */
+ initial?: readonly (string | null)[];
+ /** Called whenever the panel layout changes (add/remove/select). */
+ onChange?: (kinds: readonly (string | null)[]) => void;
+ } = $props();
- // Local UI composition state, owned by this unit and folded through the pure
- // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
- // the props (untrack makes that one-time read explicit, not reactive).
- let state = $state<PanelsState>(
- untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])),
- );
+ // Local UI composition state, owned by this unit and folded through the pure
+ // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
+ // the props (untrack makes that one-time read explicit, not reactive).
+ let state = $state<PanelsState>(untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])));
- function notify(): void {
- onChange?.(state.panels.map((p) => p.kind));
- }
+ function notify(): void {
+ onChange?.(state.panels.map((p) => p.kind));
+ }
</script>
<div class="flex min-h-0 flex-col gap-2">
- {#each state.panels as panel, idx (panel.id)}
- <div class="flex flex-col rounded-lg bg-base-200 p-3">
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- aria-label="Select a view"
- value={panel.kind ?? ""}
- onchange={(e) => {
- const v = e.currentTarget.value;
- state = selectKind(state, panel.id, v === "" ? null : v);
- notify();
- }}
- >
- <option value="" disabled>Select a view</option>
- {#each kinds as kind (kind.id)}
- <option value={kind.id}>{kind.label}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-square btn-ghost btn-sm shrink-0"
- aria-label="Remove view"
- onclick={() => {
- state = removePanel(state, panel.id);
- notify();
- }}
- >
- ✕
- </button>
- {/if}
- </div>
+ {#each state.panels as panel, idx (panel.id)}
+ <div class="flex flex-col rounded-lg bg-base-200 p-3">
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.kind ?? ""}
+ onchange={(e) => {
+ const v = e.currentTarget.value;
+ state = selectKind(state, panel.id, v === "" ? null : v);
+ notify();
+ }}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each kinds as kind (kind.id)}
+ <option value={kind.id}>{kind.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => {
+ state = removePanel(state, panel.id);
+ notify();
+ }}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
- {#if panel.kind !== null}
- <div class="mt-2">
- {@render content(panel.kind)}
- </div>
- {/if}
- </div>
- {/each}
+ {#if panel.kind !== null}
+ <div class="mt-2">
+ {@render content(panel.kind)}
+ </div>
+ {/if}
+ </div>
+ {/each}
- <button
- type="button"
- class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
- aria-label="Add view"
- onclick={() => {
- state = addPanel(state);
- notify();
- }}
- >
- +
- </button>
+ <button
+ type="button"
+ class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
+ aria-label="Add view"
+ onclick={() => {
+ state = addPanel(state);
+ notify();
+ }}
+ >
+ +
+ </button>
</div>
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: () => `<div data-testid="view-content">kind:${kind()}</div>`,
+ render: () => `<div data-testid="view-content">kind:${kind()}</div>`,
}));
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<T> =
- | { 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<readonly WorkspaceEntry[]>;
- ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- get(id: string): Promise<Workspace | null>;
- setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
- setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
- setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
- delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+ list(): Promise<readonly WorkspaceEntry[]>;
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ get(id: string): Promise<Workspace | null>;
+ setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
+ delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
async function errText(res: Response): Promise<string> {
- 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<readonly WorkspaceEntry[]> {
- 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<readonly WorkspaceEntry[]> {
+ 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<WorkspaceResult<Workspace>> {
- 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<WorkspaceResult<Workspace>> {
+ 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<Workspace | null> {
- 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<Workspace | null> {
+ 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<WorkspaceResult<Workspace>> {
- 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<WorkspaceResult<Workspace>> {
+ 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<WorkspaceResult<Workspace>> {
- 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<WorkspaceResult<Workspace>> {
+ 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<WorkspaceResult<Workspace>> {
- 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<WorkspaceResult<Workspace>> {
+ 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<WorkspaceResult<{ closedCount: number }>> {
- 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<WorkspaceResult<{ closedCount: number }>> {
+ 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<void>;
- /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
- ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- /** Rename a workspace (display only; id unchanged). */
- rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
- /** Set/clear a workspace's default cwd. */
- setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
- /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
- setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
- /** Delete a workspace (closes its conversations, reassigns to "default"). */
- remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+ /** 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<void>;
+ /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ /** Rename a workspace (display only; id unchanged). */
+ rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default cwd. */
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Delete a workspace (closes its conversations, reassigns to "default"). */
+ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
- let list = $state<readonly WorkspaceEntry[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
+ let list = $state<readonly WorkspaceEntry[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(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<void> {
- 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<void> {
+ 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<WorkspaceResult<Workspace>> {
- const result = await http.ensure(id, body);
- if (result.ok) void this.refresh();
- return result;
- },
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.ensure(id, body);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async rename(id, title): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setTitle(id, title);
- if (result.ok) void this.refresh();
- return result;
- },
+ async rename(id, title): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setTitle(id, title);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setDefaultCwd(id, defaultCwd);
- if (result.ok) void this.refresh();
- return result;
- },
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultCwd(id, defaultCwd);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setDefaultComputer(id, computerId);
- if (result.ok) void this.refresh();
- return result;
- },
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultComputer(id, computerId);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
- const result = await http.delete(id);
- if (result.ok) void this.refresh();
- return result;
- },
- };
+ async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ 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 @@
<script lang="ts">
- import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
- import type { WorkspaceStore } from "../store.svelte";
- import { relativeTime } from "../logic/view-model";
- import { workspacePath } from "../logic/route";
- import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
-
- let {
- ws,
- store,
- onNavigate,
- computers,
- }: {
- ws: WorkspaceEntry;
- store: WorkspaceStore;
- onNavigate: (path: string) => void;
- /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
- computers: readonly ComputerEntry[];
- } = $props();
-
- // ── Title: double-click to rename inline ──────────────────────────────────
- let editingTitle = $state(false);
- let titleDraft = $state("");
- let titleInput = $state<HTMLInputElement | undefined>();
- let titleError = $state<string | null>(null);
-
- function startEditTitle(): void {
- titleDraft = ws.title;
- titleError = null;
- editingTitle = true;
- queueMicrotask(() => titleInput?.focus());
- }
-
- async function saveTitle(): Promise<void> {
- if (!editingTitle) return;
- const title = titleDraft.trim();
- editingTitle = false;
- if (title === "" || title === ws.title) return;
- const result = await store.rename(ws.id, title);
- if (!result.ok) titleError = result.error;
- }
-
- function cancelTitle(): void {
- editingTitle = false;
- titleError = null;
- }
-
- // ── Default cwd: inline input ─────────────────────────────────────────────
- let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
- // Reseed when the backend value changes (e.g., after a save or an external
- // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
- $effect(() => {
- cwdDraft = ws.defaultCwd ?? "";
- });
-
- const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
- let savingCwd = $state(false);
- let cwdError = $state<string | null>(null);
-
- async function saveCwd(): Promise<void> {
- if (!cwdDirty || savingCwd) return;
- savingCwd = true;
- cwdError = null;
- const cwd = cwdDraft.trim();
- const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
- savingCwd = false;
- if (!result.ok) cwdError = result.error;
- }
-
- // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
- let savingComputer = $state(false);
- let computerError = $state<string | null>(null);
-
- async function saveComputer(computerId: string | null): Promise<void> {
- if (savingComputer) return;
- // No-op when unchanged (the select only fires on a real change, but guard).
- if (computerId === (ws.defaultComputerId ?? null)) return;
- savingComputer = true;
- computerError = null;
- const result = await store.setDefaultComputer(ws.id, computerId);
- savingComputer = false;
- if (!result.ok) computerError = result.error;
- }
-
- // ── Delete ─────────────────────────────────────────────────────────────────
- let deleting = $state(false);
-
- async function handleDelete(): Promise<void> {
- if (
- !window.confirm(
- `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
- )
- ) {
- return;
- }
- deleting = true;
- await store.remove(ws.id);
- deleting = false;
- }
+ import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
+ import { untrack } from "svelte";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import { workspacePath } from "../logic/route";
+ import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
+
+ let {
+ ws,
+ store,
+ onNavigate,
+ computers,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
+ } = $props();
+
+ // ── Title: double-click to rename inline ──────────────────────────────────
+ let editingTitle = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEditTitle(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editingTitle = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ async function saveTitle(): Promise<void> {
+ if (!editingTitle) return;
+ const title = titleDraft.trim();
+ editingTitle = false;
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editingTitle = false;
+ titleError = null;
+ }
+
+ // ── Default cwd: inline input ─────────────────────────────────────────────
+ let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
+ // Reseed when the backend value changes (e.g., after a save or an external
+ // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
+ $effect(() => {
+ cwdDraft = ws.defaultCwd ?? "";
+ });
+
+ const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
+ let savingCwd = $state(false);
+ let cwdError = $state<string | null>(null);
+
+ async function saveCwd(): Promise<void> {
+ if (!cwdDirty || savingCwd) return;
+ savingCwd = true;
+ cwdError = null;
+ const cwd = cwdDraft.trim();
+ const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
+ savingCwd = false;
+ if (!result.ok) cwdError = result.error;
+ }
+
+ // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
+ let savingComputer = $state(false);
+ let computerError = $state<string | null>(null);
+
+ async function saveComputer(computerId: string | null): Promise<void> {
+ if (savingComputer) return;
+ // No-op when unchanged (the select only fires on a real change, but guard).
+ if (computerId === (ws.defaultComputerId ?? null)) return;
+ savingComputer = true;
+ computerError = null;
+ const result = await store.setDefaultComputer(ws.id, computerId);
+ savingComputer = false;
+ if (!result.ok) computerError = result.error;
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ let deleting = $state(false);
+
+ async function handleDelete(): Promise<void> {
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
+ return;
+ }
+ deleting = true;
+ await store.remove(ws.id);
+ deleting = false;
+ }
</script>
<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3">
- <div class="flex items-center gap-2">
- {#if editingTitle}
- <input
- bind:this={titleInput}
- bind:value={titleDraft}
- class="input input-bordered input-sm flex-1"
- aria-label="Workspace title"
- onkeydown={(e) => {
- if (e.key === "Enter") saveTitle();
- else if (e.key === "Escape") cancelTitle();
- }}
- onblur={saveTitle}
- />
- {:else}
- <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
- <span
- class="flex-1 cursor-default truncate font-semibold"
- title="Double-click to rename"
- ondblclick={startEditTitle}
- >{ws.title}</span>
- {/if}
- <span class="font-mono text-xs opacity-50">/{ws.id}</span>
- <span class="ml-auto text-xs opacity-50">
- {ws.conversationCount}
- {ws.conversationCount === 1 ? "conversation" : "conversations"}
- · {relativeTime(ws.lastActivityAt, Date.now())}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={deleting}
- title="Delete workspace"
- aria-label="Delete workspace"
- onclick={handleDelete}
- >
- {#if deleting}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- ✕
- {/if}
- </button>
- </div>
-
- {#if titleError}
- <p class="text-xs text-error">{titleError}</p>
- {/if}
-
- <div class="flex items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
- <input
- type="text"
- class="input input-bordered input-sm flex-1 font-mono text-xs"
- placeholder="inherits the server default"
- bind:value={cwdDraft}
- aria-label="Default working directory"
- onkeydown={(e) => {
- if (e.key === "Enter") saveCwd();
- }}
- />
- <button
- type="button"
- class="btn btn-primary btn-xs"
- disabled={!cwdDirty || savingCwd}
- onclick={saveCwd}
- >
- {#if savingCwd}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
-
- <div class="flex items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
- <ComputerSelect
- value={ws.defaultComputerId}
- {computers}
- disabled={savingComputer}
- onSelect={saveComputer}
- />
- {#if savingComputer}
- <span class="loading loading-spinner loading-xs shrink-0"></span>
- {/if}
- </div>
-
- <div class="flex justify-start">
- <a
- class="btn"
- href={workspacePath(ws.id)}
- target="_blank"
- rel="noopener noreferrer"
- >
- Open
- </a>
- </div>
-
- {#if cwdError}
- <p class="text-xs text-error">{cwdError}</p>
- {:else if !cwdDirty && !ws.defaultCwd}
- <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
- {/if}
-
- {#if computerError}
- <p class="text-xs text-error">{computerError}</p>
- {:else if !ws.defaultComputerId}
- <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
- {/if}
+ <div class="flex items-center gap-2">
+ {#if editingTitle}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-bordered input-sm flex-1"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={saveTitle}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class="flex-1 cursor-default truncate font-semibold"
+ title="Double-click to rename"
+ ondblclick={startEditTitle}>{ws.title}</span
+ >
+ {/if}
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+ <span class="ml-auto text-xs opacity-50">
+ {ws.conversationCount}
+ {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ · {relativeTime(ws.lastActivityAt, Date.now())}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={deleting}
+ title="Delete workspace"
+ aria-label="Delete workspace"
+ onclick={handleDelete}
+ >
+ {#if deleting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+
+ {#if titleError}
+ <p class="text-xs text-error">{titleError}</p>
+ {/if}
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
+ <input
+ type="text"
+ class="input input-bordered input-sm flex-1 font-mono text-xs"
+ placeholder="inherits the server default"
+ bind:value={cwdDraft}
+ aria-label="Default working directory"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveCwd();
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!cwdDirty || savingCwd}
+ onclick={saveCwd}
+ >
+ {#if savingCwd}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
+ <ComputerSelect
+ value={ws.defaultComputerId}
+ {computers}
+ disabled={savingComputer}
+ onSelect={saveComputer}
+ />
+ {#if savingComputer}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ <div class="flex justify-start">
+ <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> Open </a>
+ </div>
+
+ {#if cwdError}
+ <p class="text-xs text-error">{cwdError}</p>
+ {:else if !cwdDirty && !ws.defaultCwd}
+ <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
+ {/if}
+
+ {#if computerError}
+ <p class="text-xs text-error">{computerError}</p>
+ {:else if !ws.defaultComputerId}
+ <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
+ {/if}
</li>
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> = {}): 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<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, title: _title }),
- }),
- ),
- setDefaultCwd: vi.fn(
- async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultCwd }),
- }),
- ),
- setDefaultComputer: vi.fn(
- async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultComputerId: computerId }),
- }),
- ),
- remove: vi.fn(
- async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
- ok: true,
- value: { closedCount: 0 },
- }),
- ),
- };
+ return {
+ rename: vi.fn(
+ async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, title: _title }),
+ }),
+ ),
+ setDefaultCwd: vi.fn(
+ async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultCwd }),
+ }),
+ ),
+ setDefaultComputer: vi.fn(
+ async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultComputerId: computerId }),
+ }),
+ ),
+ remove: vi.fn(
+ async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
+ 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 @@
<script lang="ts">
- import { onMount } from "svelte";
- import type { ComputerEntry } from "@dispatch/wire";
- import type { WorkspaceStore } from "../store.svelte";
- import { isValidSlug, workspacePath } from "../logic/route";
- import WorkspaceCard from "./WorkspaceCard.svelte";
+ import { onMount } from "svelte";
+ import type { ComputerEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { isValidSlug, workspacePath } from "../logic/route";
+ import WorkspaceCard from "./WorkspaceCard.svelte";
- let {
- store,
- onNavigate,
- computers,
- }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } =
- $props();
+ let {
+ store,
+ onNavigate,
+ computers,
+ }: {
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ computers: readonly ComputerEntry[];
+ } = $props();
- onMount(() => {
- void store.refresh();
- });
+ onMount(() => {
+ void store.refresh();
+ });
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
- return;
- }
- slugError = null;
- newSlug = "";
- onNavigate(workspacePath(slug));
- }
+ function createWorkspace(): void {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ newSlug = "";
+ onNavigate(workspacePath(slug));
+ }
</script>
<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
- <header class="flex items-center justify-between">
- <h1 class="text-2xl font-bold">Workspaces</h1>
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
- </header>
+ <header class="flex items-center justify-between">
+ <h1 class="text-2xl font-bold">Workspaces</h1>
+ <a
+ href="/default"
+ class="btn btn-ghost btn-sm"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/default");
+ }}
+ >
+ Default
+ </a>
+ </header>
- <form
- class="flex items-end gap-2"
- onsubmit={(e) => {
- e.preventDefault();
- createWorkspace();
- }}
- >
- <div class="flex-1">
- <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60">New workspace</label>
- <input
- id="new-ws"
- class="input input-bordered w-full"
- placeholder="my-workspace"
- bind:value={newSlug}
- autocomplete="off"
- spellcheck="false"
- />
- </div>
- <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
- </form>
- {#if slugError}
- <p class="text-xs text-error">{slugError}</p>
- {/if}
+ <form
+ class="flex items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ createWorkspace();
+ }}
+ >
+ <div class="flex-1">
+ <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60"
+ >New workspace</label
+ >
+ <input
+ id="new-ws"
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
+ </form>
+ {#if slugError}
+ <p class="text-xs text-error">{slugError}</p>
+ {/if}
- <div class="flex-1 overflow-y-auto">
- {#if store.loading && store.list.length === 0}
- <div class="flex h-32 items-center justify-center">
- <span class="loading loading-spinner loading-sm opacity-60"></span>
- </div>
- {:else if store.list.length === 0}
- <p class="py-8 text-center text-sm opacity-60">
- No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
- </p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard {ws} {store} {onNavigate} {computers} />
- {/each}
- </ul>
- {/if}
- </div>
+ <div class="flex-1 overflow-y-auto">
+ {#if store.loading && store.list.length === 0}
+ <div class="flex h-32 items-center justify-center">
+ <span class="loading loading-spinner loading-sm opacity-60"></span>
+ </div>
+ {:else if store.list.length === 0}
+ <p class="py-8 text-center text-sm opacity-60">
+ No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
+ </p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each store.list as ws (ws.id)}
+ <WorkspaceCard {ws} {store} {onNavigate} {computers} />
+ {/each}
+ </ul>
+ {/if}
+ </div>
</div>
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 });
diff --git a/svelte.config.js b/svelte.config.js
index f77d881..d0e6448 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -1,5 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
- preprocess: vitePreprocess(),
+ preprocess: vitePreprocess(),
};
diff --git a/tsconfig.json b/tsconfig.json
index 02d6f89..b5a5eb5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,18 +1,18 @@
{
- "extends": "@tsconfig/svelte/tsconfig.json",
- "compilerOptions": {
- "target": "ES2022",
- "module": "ESNext",
- "moduleResolution": "bundler",
- "types": ["vite/client"],
- "strict": true,
- "noUncheckedIndexedAccess": true,
- "noImplicitOverride": true,
- "exactOptionalPropertyTypes": true,
- "verbatimModuleSyntax": true,
- "isolatedModules": true,
- "skipLibCheck": true,
- "noEmit": true
- },
- "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.d.ts", "vitest-setup.ts"]
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["vite/client"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "exactOptionalPropertyTypes": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "src/**/*.d.ts", "vitest-setup.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index bdfabe6..3229445 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,31 +7,31 @@ import { defineConfig } from "vitest/config";
// Dev server on the reserved FRONTEND_PORT (24204). Vitest config lives here too
// (jsdom + globals) so component tests run without extra config.
export default defineConfig({
- // svelteTesting() forces Svelte's `browser` resolve condition under vitest so
- // component render()/mount() works in jsdom (a plain test.resolve.conditions
- // does not propagate to Vite's SSR resolution — sveltejs/svelte#11394).
- plugins: [tailwindcss(), svelte(), svelteTesting()],
- // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN /
- // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true`
- // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set
- // VITE_WS_URL to the backend's reachable host when browsing from another device.)
- server: { port: 24204, host: true, allowedHosts: true },
- define: {
- // Bake the 5-char git short hash at build time so it survives bundling
- // (incl. arch package deploys). Falls back to "dev" when not in a git repo.
- __APP_VERSION__: JSON.stringify(getGitShortHash()),
- },
- test: {
- environment: "jsdom",
- globals: true,
- setupFiles: ["./vitest-setup.ts"],
- },
+ // svelteTesting() forces Svelte's `browser` resolve condition under vitest so
+ // component render()/mount() works in jsdom (a plain test.resolve.conditions
+ // does not propagate to Vite's SSR resolution — sveltejs/svelte#11394).
+ plugins: [tailwindcss(), svelte(), svelteTesting()],
+ // Bind all interfaces + accept any Host header so the dev server is reachable over a LAN /
+ // Tailscale. Safe for LOCAL-NETWORK-ONLY use (NOT internet-exposed): `allowedHosts: true`
+ // disables Vite's DNS-rebinding host check. (The WS URL still runs in the browser — set
+ // VITE_WS_URL to the backend's reachable host when browsing from another device.)
+ server: { port: 24204, host: true, allowedHosts: true },
+ define: {
+ // Bake the 5-char git short hash at build time so it survives bundling
+ // (incl. arch package deploys). Falls back to "dev" when not in a git repo.
+ __APP_VERSION__: JSON.stringify(getGitShortHash()),
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest-setup.ts"],
+ },
});
function getGitShortHash(): string {
- try {
- return execSync("git rev-parse --short=5 HEAD", { encoding: "utf-8" }).trim();
- } catch {
- return "dev";
- }
+ try {
+ return execSync("git rev-parse --short=5 HEAD", { encoding: "utf-8" }).trim();
+ } catch {
+ return "dev";
+ }
}
diff --git a/vitest-setup.ts b/vitest-setup.ts
index 10a3160..143ac96 100644
--- a/vitest-setup.ts
+++ b/vitest-setup.ts
@@ -5,12 +5,12 @@ import "fake-indexeddb/auto";
// controller uses both against the real transcript element when App mounts. Stub
// the outermost edges so component tests can render without throwing.
if (typeof Element !== "undefined" && typeof Element.prototype.scrollTo !== "function") {
- Element.prototype.scrollTo = () => {};
+ Element.prototype.scrollTo = () => {};
}
if (typeof globalThis.ResizeObserver === "undefined") {
- globalThis.ResizeObserver = class {
- observe(): void {}
- unobserve(): void {}
- disconnect(): void {}
- };
+ globalThis.ResizeObserver = class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ };
}