summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dispatch/package-agent.md80
-rw-r--r--.dispatch/rules/daisyui.md1967
-rw-r--r--.dispatch/rules/frontend-inject-transport.md7
-rw-r--r--.dispatch/rules/frontend-interpreter-generic.md8
-rw-r--r--.dispatch/rules/frontend-no-ambient-state.md7
-rw-r--r--.dispatch/rules/frontend-pure-core.md7
-rw-r--r--.dispatch/rules/frontend-styling.md12
-rw-r--r--.dispatch/transport-contract.reference.md1114
-rw-r--r--.dispatch/ui-contract.reference.md127
-rw-r--r--.dispatch/wire.reference.md555
-rw-r--r--.env.development2
-rw-r--r--.env.production2
-rw-r--r--.prettierrc.json15
-rw-r--r--.tool-versions1
-rw-r--r--AGENTS.md174
-rw-r--r--GLOSSARY.md24
-rw-r--r--ORCHESTRATOR.md209
-rw-r--r--README.md26
-rw-r--r--ROADMAP.md39
-rw-r--r--backend-handoff.md97
-rw-r--r--biome.json25
-rw-r--r--bun.lock38
-rw-r--r--index.html2
-rw-r--r--notes/assumptions-log.md58
-rw-r--r--package.json88
-rwxr-xr-xscripts/fix-dist-perms.sh25
-rw-r--r--scripts/live-probe-provider-retry.ts188
-rw-r--r--scripts/live-probe.ts479
-rw-r--r--scripts/probe-cache-warming.ts277
-rw-r--r--src/App.svelte78
-rw-r--r--src/adapters/history/index.test.ts98
-rw-r--r--src/adapters/history/index.ts93
-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/portal.test.ts49
-rw-r--r--src/adapters/portal.ts28
-rw-r--r--src/adapters/ws/index.test.ts729
-rw-r--r--src/adapters/ws/index.ts183
-rw-r--r--src/adapters/ws/logic.test.ts626
-rw-r--r--src/adapters/ws/logic.ts238
-rw-r--r--src/app.css129
-rw-r--r--src/app/App.svelte879
-rw-r--r--src/app/App.test.ts940
-rw-r--r--src/app/ErrorModal.svelte150
-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.ts2466
-rw-r--r--src/app/store.test.ts2786
-rw-r--r--src/app/uuid.test.ts44
-rw-r--r--src/app/uuid.ts92
-rw-r--r--src/components/Table.svelte42
-rw-r--r--src/components/Table.test.ts35
-rw-r--r--src/core/chunks/groups.test.ts125
-rw-r--r--src/core/chunks/groups.ts95
-rw-r--r--src/core/chunks/image-url.test.ts46
-rw-r--r--src/core/chunks/image-url.ts35
-rw-r--r--src/core/chunks/index.ts35
-rw-r--r--src/core/chunks/reducer.test.ts1343
-rw-r--r--src/core/chunks/reducer.ts546
-rw-r--r--src/core/chunks/retry-banner.test.ts63
-rw-r--r--src/core/chunks/retry-banner.ts51
-rw-r--r--src/core/chunks/selectors.ts88
-rw-r--r--src/core/chunks/trim.test.ts235
-rw-r--r--src/core/chunks/trim.ts185
-rw-r--r--src/core/chunks/types.ts76
-rw-r--r--src/core/metrics/format.test.ts375
-rw-r--r--src/core/metrics/format.ts179
-rw-r--r--src/core/metrics/index.ts31
-rw-r--r--src/core/metrics/place.test.ts621
-rw-r--r--src/core/metrics/place.ts298
-rw-r--r--src/core/metrics/reducer.test.ts689
-rw-r--r--src/core/metrics/reducer.ts345
-rw-r--r--src/core/metrics/types.ts97
-rw-r--r--src/core/protocol/index.ts11
-rw-r--r--src/core/protocol/reducer.test.ts340
-rw-r--r--src/core/protocol/reducer.ts171
-rw-r--r--src/core/protocol/types.ts39
-rw-r--r--src/core/wire/conformance.test.ts403
-rw-r--r--src/core/wire/conformance.ts162
-rw-r--r--src/core/wire/index.ts8
-rw-r--r--src/features/cache-warming/index.ts8
-rw-r--r--src/features/cache-warming/logic/view-model.test.ts228
-rw-r--r--src/features/cache-warming/logic/view-model.ts254
-rw-r--r--src/features/cache-warming/ui/CacheWarmingView.svelte244
-rw-r--r--src/features/chat/index.ts34
-rw-r--r--src/features/chat/model-select.test.ts89
-rw-r--r--src/features/chat/model-select.ts64
-rw-r--r--src/features/chat/ports.ts43
-rw-r--r--src/features/chat/reasoning-effort.test.ts45
-rw-r--r--src/features/chat/reasoning-effort.ts66
-rw-r--r--src/features/chat/store.svelte.ts503
-rw-r--r--src/features/chat/store.test.ts2101
-rw-r--r--src/features/chat/test-helpers.ts185
-rw-r--r--src/features/chat/ui.test.ts1391
-rw-r--r--src/features/chat/ui/ChatView.svelte405
-rw-r--r--src/features/chat/ui/CompactionView.svelte154
-rw-r--r--src/features/chat/ui/Composer.svelte438
-rw-r--r--src/features/chat/ui/ModelSelector.svelte108
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte75
-rw-r--r--src/features/computer/index.ts31
-rw-r--r--src/features/computer/logic/view-model.test.ts167
-rw-r--r--src/features/computer/logic/view-model.ts184
-rw-r--r--src/features/computer/ui/ComputerField.svelte211
-rw-r--r--src/features/computer/ui/ComputerSelect.svelte39
-rw-r--r--src/features/concurrency/index.ts63
-rw-r--r--src/features/concurrency/logic/types.ts119
-rw-r--r--src/features/concurrency/logic/view-model.test.ts670
-rw-r--r--src/features/concurrency/logic/view-model.ts487
-rw-r--r--src/features/concurrency/ui/AutoReduceBanner.svelte81
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte204
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte433
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts559
-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.ts12
-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.ts14
-rw-r--r--src/features/cwd-lsp/logic/view-model.test.ts101
-rw-r--r--src/features/cwd-lsp/logic/view-model.ts130
-rw-r--r--src/features/cwd-lsp/ui/CwdField.svelte96
-rw-r--r--src/features/cwd-lsp/ui/LspStatusView.svelte129
-rw-r--r--src/features/heartbeat/index.ts50
-rw-r--r--src/features/heartbeat/logic/types.ts103
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts470
-rw-r--r--src/features/heartbeat/logic/view-model.ts410
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte536
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte412
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts167
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte176
-rw-r--r--src/features/markdown/index.ts8
-rw-r--r--src/features/markdown/logic/markdown.test.ts58
-rw-r--r--src/features/markdown/logic/markdown.ts165
-rw-r--r--src/features/markdown/ui/Markdown.svelte58
-rw-r--r--src/features/markdown/ui/markdown.test.ts40
-rw-r--r--src/features/mcp/index.ts8
-rw-r--r--src/features/mcp/logic/view-model.test.ts88
-rw-r--r--src/features/mcp/logic/view-model.ts110
-rw-r--r--src/features/mcp/ui/McpStatusView.svelte133
-rw-r--r--src/features/settings/index.ts9
-rw-r--r--src/features/settings/logic/view-model.test.ts61
-rw-r--r--src/features/settings/logic/view-model.ts60
-rw-r--r--src/features/settings/ui/ChatLimitField.svelte104
-rw-r--r--src/features/settings/ui/ChatLimitField.test.ts89
-rw-r--r--src/features/smart-scroll/index.ts25
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.test.ts103
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts93
-rw-r--r--src/features/smart-scroll/ui/ScrollToBottom.svelte36
-rw-r--r--src/features/smart-scroll/ui/controller.svelte.ts140
-rw-r--r--src/features/smart-scroll/ui/controller.test.ts172
-rw-r--r--src/features/surface-host/index.ts6
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts48
-rw-r--r--src/features/surface-host/logic/message-queue.ts45
-rw-r--r--src/features/surface-host/logic/plan.test.ts389
-rw-r--r--src/features/surface-host/logic/plan.ts178
-rw-r--r--src/features/surface-host/logic/table.test.ts47
-rw-r--r--src/features/surface-host/logic/table.ts54
-rw-r--r--src/features/surface-host/logic/todo.test.ts67
-rw-r--r--src/features/surface-host/logic/todo.ts49
-rw-r--r--src/features/surface-host/logic/types.ts86
-rw-r--r--src/features/surface-host/ui/Button.svelte30
-rw-r--r--src/features/surface-host/ui/MessageQueueList.svelte22
-rw-r--r--src/features/surface-host/ui/Number.svelte43
-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/Stat.svelte10
-rw-r--r--src/features/surface-host/ui/StatTable.svelte21
-rw-r--r--src/features/surface-host/ui/SurfaceTable.svelte14
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte68
-rw-r--r--src/features/surface-host/ui/TodoList.svelte66
-rw-r--r--src/features/surface-host/ui/Toggle.svelte36
-rw-r--r--src/features/system-prompt/index.ts17
-rw-r--r--src/features/system-prompt/logic/view-model.test.ts90
-rw-r--r--src/features/system-prompt/logic/view-model.ts106
-rw-r--r--src/features/system-prompt/ui/SystemPromptBuilder.svelte243
-rw-r--r--src/features/tabs/index.ts28
-rw-r--r--src/features/tabs/tabs-store.svelte.ts112
-rw-r--r--src/features/tabs/tabs-store.test.ts282
-rw-r--r--src/features/tabs/tabs.test.ts348
-rw-r--r--src/features/tabs/tabs.ts110
-rw-r--r--src/features/tabs/ui.test.ts430
-rw-r--r--src/features/tabs/ui/TabBar.svelte54
-rw-r--r--src/features/tabs/ui/TabList.svelte183
-rw-r--r--src/features/views/index.ts15
-rw-r--r--src/features/views/logic/panels.test.ts55
-rw-r--r--src/features/views/logic/panels.ts49
-rw-r--r--src/features/views/ui/ViewSidebar.svelte95
-rw-r--r--src/features/views/ui/ViewSidebar.test.ts58
-rw-r--r--src/features/vision/index.ts30
-rw-r--r--src/features/vision/logic/view-model.test.ts198
-rw-r--r--src/features/vision/logic/view-model.ts189
-rw-r--r--src/features/vision/ui/VisionSettingsView.svelte192
-rw-r--r--src/features/vision/ui/VisionSettingsView.test.ts241
-rw-r--r--src/features/workspaces/adapter/http.test.ts189
-rw-r--r--src/features/workspaces/adapter/http.ts187
-rw-r--r--src/features/workspaces/index.ts21
-rw-r--r--src/features/workspaces/logic/route.test.ts77
-rw-r--r--src/features/workspaces/logic/route.ts63
-rw-r--r--src/features/workspaces/logic/view-model.test.ts179
-rw-r--r--src/features/workspaces/logic/view-model.ts71
-rw-r--r--src/features/workspaces/store.svelte.ts114
-rw-r--r--src/features/workspaces/store.test.ts145
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte279
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts305
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte110
-rw-r--r--src/main.ts2
-rw-r--r--src/themes/monokai.css40
-rw-r--r--src/vite-env.d.ts3
-rw-r--r--svelte.config.js2
-rw-r--r--tsconfig.json32
-rw-r--r--vite.config.ts42
-rw-r--r--vitest-setup.ts14
217 files changed, 37276 insertions, 6954 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md
deleted file mode 100644
index 73e960c..0000000
--- a/.dispatch/package-agent.md
+++ /dev/null
@@ -1,80 +0,0 @@
-<!-- ORCHESTRATOR-ONLY meta (see ORCHESTRATOR.md): every FE summon is assembled as
- package-agent.md + the inlined .dispatch/rules/* + the per-summon TASK block.
- This is the base for ALL FE owner-agents; nothing here is restated per summon. -->
-
-# Frontend Owner-Agent — Brief
-
-You are the **sole owner-agent for exactly ONE unit** — a single feature module /
-directory under `src/`. Your unit + job are in the **TASK** at the end. You build
-it, test it, and write a report — nothing else. If no single unit is named, stop.
-
-## Hard guardrails (NON-NEGOTIABLE)
-- **Single-writer, directory-scoped.** Read/create/edit any file inside your unit's
- directory. Never create or edit anything OUTSIDE it — not another feature, not
- `src/app/` (the composition root), not root config (`package.json`,
- `tsconfig.json`, `vite.config.ts`, `biome.json`), not the harness, not the
- backend repo (`../arch-rewrite`).
-- **Need a change outside your unit?** Do NOT make it — write a CHANGE-REQUEST in
- your report (a sibling's public export, a backend contract, root config,
- composition wiring). The orchestrator dispatches it.
-- **No workspace/dep wiring.** Don't `bun install` or edit root config; list a new
- dep / wiring need as a CR.
-- **No git** (no commits, branches, pushes, resets).
-
-## What you may read (visibility)
-- **Your own unit:** every file, freely.
-- **The contracts you consume:** reproduced IN-REPO under `.dispatch/*.reference.md` — read THOSE:
- - `.dispatch/ui-contract.reference.md` — `@dispatch/ui-contract` (surfaces + surface WS protocol).
- - `.dispatch/wire.reference.md` — `@dispatch/wire` (`Chunk`/`StoredChunk`+`seq`/`ChatMessage`/
- `AgentEvent`/`TurnSealedEvent`/`Usage` — the chat wire types).
- - `.dispatch/transport-contract.reference.md` — `@dispatch/transport-contract` (HTTP endpoints +
- `ChatRequest`/`ModelsResponse`/`ConversationHistoryResponse` + WS chat ops + the unified
- `WsClientMessage`/`WsServerMessage` unions).
-
- Your code imports `@dispatch/ui-contract` / `@dispatch/wire` / `@dispatch/transport-contract`
- normally, but **do NOT read `node_modules/@dispatch/*`** — they symlink to the backend repo
- (OUTSIDE this repo) and a headless permission prompt will HANG the run (see "Headless read
- boundary").
-- **Sibling units — PUBLIC SURFACE only:** their `index.ts` exports. Don't read
- their internals (needing them ⇒ the contract is incomplete → report a CR).
-
-## Headless read boundary (you run non-interactively)
-You run HEADLESS: a Read of any file OUTSIDE this repo (`dispatch-web/`) triggers a
-permission prompt that CANNOT be answered → the run HANGS until aborted. Use Read/Edit ONLY
-within `dispatch-web/`. If you believe you need a file outside your scope, do NOT attempt
-the read — STOP and write the need in your report, then end.
-
-## Engineering standard (the inlined `.dispatch/rules/*` govern; in brief)
-- **Pure core / injected shell.** Decision logic is `input → output`: zero DOM,
- zero `fetch`/WS, zero Svelte. Put it in a `.ts` module (e.g. `logic/`) that tests
- with NO mounting and NO mocks. Effects are INJECTED (props or an `adapter/`).
-- **Svelte-thin.** `.svelte` files wire props/events to pure logic + render; no
- business logic. (biome lints `.ts`/`.js` only; `svelte-check` owns `.svelte`.)
-- **No ambient state.** Own state explicitly; runes wrap the pure reducer;
- subscriptions are disposed on unmount.
-- **Tests, asymmetric.** Pure logic → vitest with ZERO internal mocks (never
- `vi.mock` of our own modules). Components → a few `@testing-library/svelte`
- tests; don't chase coverage there, don't mock siblings. Faking the OUTERMOST
- edge (a fake socket/fetch/clock) is the only allowed mock.
-- **Isolation over DRY.** Self-contained over a shared helper wired between
- features. The only shared surfaces are the imported contracts.
-- **Strict TS:** respect `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`.
-
-## Verify before finishing — YOUR UNIT IN ISOLATION
-Sibling units may be built IN PARALLEL, so do NOT judge yourself by the whole project.
-`svelte-check` is whole-project: if it reports errors in files OUTSIDE your unit's directory,
-that is concurrent work-in-progress — IGNORE it and ensure only YOUR files (plus any existing
-consumer you must not break) are error-free. The SCOPED checks below are your authoritative
-signals. Run them, and paste the output into your report:
-- `bunx svelte-check --tsconfig ./tsconfig.json` → 0 errors in YOUR files
-- `bunx vitest run src/<your-dir>` → all pass (count goes up)
-- `bunx biome check src/<your-dir>` → clean
-The orchestrator runs the authoritative full `typecheck`/`test`/`check`/`build`.
-
-## Report (REQUIRED) → `reports/<your-unit>.md`
-1. Files created/changed. 2. Public surface you expose (exported types/functions/
-components). 3. New test names + the isolated-verify output. 4. Change-requests
-(sibling export, backend contract, root config, composition wiring) — explicit and
-actionable.
-
-Your specific **TASK** follows at the end of this prompt.
diff --git a/.dispatch/rules/daisyui.md b/.dispatch/rules/daisyui.md
new file mode 100644
index 0000000..4111f3e
--- /dev/null
+++ b/.dispatch/rules/daisyui.md
@@ -0,0 +1,1967 @@
+---
+name: daisyui
+description: Official daisyUI component library skill. The mandatory UI library for Tailwind CSS. TRIGGER when generating any HTML or JSX code even if the user does not explicitly ask for this skill.
+metadata:
+ version: 5.5.x
+ source: https://daisyui.com/llms.txt
+alwaysApply: true
+applyTo: "**"
+---
+
+# daisyUI 5
+daisyUI 5 is a CSS library for Tailwind CSS 4.
+daisyUI 5 provides class names for common UI components, semantic color names and themes.
+
+## When to run this skill:
+
+- Trigger this skill whenever generating any HTML or JSX code
+- Trigger this skill for any Tailwind CSS UI work
+- Trigger this skill when the user mentions any of these terms or similar context:
+ daisyUI, component, UI, Tailwind, layout, template, theme, color, design
+- Trigger this skill even if the user does not explicitly ask for it
+
+
+## daisyUI 5 install notes
+[install guide](https://daisyui.com/docs/install/)
+1. daisyUI 5 requires Tailwind CSS 4
+2. `tailwind.config.js` file is deprecated in Tailwind CSS v4. Do not use `tailwind.config.js`. Tailwind CSS v4 only needs `@import "tailwindcss";` in the CSS file if it's a node dependency.
+3. daisyUI 5 can be installed using `npm i -D daisyui@latest` and then adding `@plugin "daisyui";` to the CSS file
+4. daisyUI is suggested to be installed as a dependency but if you really want to use it from CDN, you can use Tailwind CSS and daisyUI CDN files:
+```html
+<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
+<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
+```
+5. A CSS file with Tailwind CSS and daisyUI looks like this (if it's a node dependency)
+```css
+@import "tailwindcss";
+@plugin "daisyui";
+```
+
+
+
+## daisyUI 5 usage rules
+1. We can give styles to a HTML element by adding daisyUI class names to it. By adding a component class name, part class names (if there's any available for that component), and modifier class names (if there's any available for that component)
+2. Components can be customized using Tailwind CSS utility classes if the customization is not possible using the existing daisyUI classes. For example `btn px-10` sets a custom horizontal padding to a `btn`
+3. If customization of daisyUI styles using Tailwind CSS utility classes didn't work because of CSS specificity issues, you can use the `!` at the end of the Tailwind CSS utility class to override the existing styles. For example `btn bg-red-500!` sets a custom background color to a `btn` forcefully. This is a last resort solution and should be used sparingly
+4. If a specific component or something similar to it doesn't exist in daisyUI, you can create your own component using Tailwind CSS utility
+5. when using Tailwind CSS `flex` and `grid` for layout, it should be responsive using Tailwind CSS responsive utility prefixes.
+6. Only allowed class names are existing daisyUI class names or Tailwind CSS utility classes.
+7. Ideally, you won't need to write any custom CSS. Using daisyUI class names or Tailwind CSS utility classes is preferred.
+8. Suggested - if you need placeholder images, use https://picsum.photos/200/300 with the size you want
+9. Suggested - when designing, don't add a custom font unless it's necessary
+10. Don't add `bg-base-100 text-base-content` to body unless it's necessary
+11. For design decisions, use Refactoring UI book best practices
+12. Always use the default variant of daisyUI components unless the user asked for a specific variant or color
+
+daisyUI 5 class names are one of the following categories. These type names are only for reference and are not used in the actual code
+- `component`: the required component class
+- `part`: a child part of a component
+- `style`: sets a specific style to component or part
+- `behavior`: changes the behavior of component or part
+- `color`: sets a specific color to component or part
+- `size`: sets a specific size to component or part
+- `placement`: sets a specific placement to component or part
+- `direction`: sets a specific direction to component or part
+- `modifier`: modifies the component or part in a specific way
+- `variant`: prefixes for utility classes that conditionally apply styles. syntax is `variant:utility-class`
+
+
+
+## Config
+daisyUI 5 config docs: https://daisyui.com/docs/config/
+daisyUI without config:
+```css
+@plugin "daisyui";
+```
+daisyUI config with `light` theme only:
+```css
+@plugin "daisyui" {
+ themes: light --default;
+}
+```
+daisyUI with all the default configs:
+```css
+@plugin "daisyui" {
+ themes: light --default, dark --prefersdark;
+ root: ":root";
+ include: ;
+ exclude: ;
+ prefix: ;
+ logs: true;
+}
+```
+An example config:
+In below config, all the built-in themes are enabled while bumblebee is the default theme and synthwave is the prefersdark theme (default dark mode)
+All the other themes are enabled and can be used by adding `data-theme="THEME_NAME"` to the `<html>` element
+root scrollbar gutter is excluded. `daisy-` prefix is used for all daisyUI classes and console.log is disabled
+```css
+@plugin "daisyui" {
+ themes: light, dark, cupcake, bumblebee --default, emerald, corporate, synthwave --prefersdark, retro, cyberpunk, valentine, halloween, garden, forest, aqua, lofi, pastel, fantasy, wireframe, black, luxury, dracula, cmyk, autumn, business, acid, lemonade, night, coffee, winter, dim, nord, sunset, caramellatte, abyss, silk;
+ root: ":root";
+ include: ;
+ exclude: rootscrollgutter, checkbox;
+ prefix: daisy-;
+ logs: false;
+}
+```
+
+
+
+## daisyUI 5 colors
+
+### daisyUI color names
+- `primary`: Primary brand color, The main color of your brand
+- `primary-content`: Foreground content color to use on primary color
+- `secondary`: Secondary brand color, The optional, secondary color of your brand
+- `secondary-content`: Foreground content color to use on secondary color
+- `accent`: Accent brand color, The optional, accent color of your brand
+- `accent-content`: Foreground content color to use on accent color
+- `neutral`: Neutral dark color, For not-saturated parts of UI
+- `neutral-content`: Foreground content color to use on neutral color
+- `base-100`:-100 Base surface color of page, used for blank backgrounds
+- `base-200`:-200 Base color, darker shade, to create elevations
+- `base-300`:-300 Base color, even more darker shade, to create elevations
+- `base-content`: Foreground content color to use on base color
+- `info`: Info color, For informative/helpful messages
+- `info-content`: Foreground content color to use on info color
+- `success`: Success color, For success/safe messages
+- `success-content`: Foreground content color to use on success color
+- `warning`: Warning color, For warning/caution messages
+- `warning-content`: Foreground content color to use on warning color
+- `error`: Error color, For error/danger/destructive messages
+- `error-content`: Foreground content color to use on error color
+
+### daisyUI color rules
+1. daisyUI adds semantic color names to Tailwind CSS colors
+2. daisyUI color names can be used in utility classes, like other Tailwind CSS color names. For example, `bg-primary` will use the primary color for the background
+3. daisyUI color names include variables as value so they can change based on the theme
+4. There's no need to use `dark:` for daisyUI color names
+5. Ideally only daisyUI color names should be used for colors so the colors can change automatically based on the theme
+6. If a Tailwind CSS color name (like `red-500`) is used, it will be the same red color on all themes
+7. If a daisyUI color name (like `primary`) is used, it will change color based on the theme
+8. Using Tailwind CSS color names for text colors should be avoided because Tailwind CSS color `text-gray-800` on `bg-base-100` would be unreadable on a dark theme - because on dark theme, `bg-base-100` is a dark color
+9. `*-content` colors should have a good contrast compared to their associated colors
+10. Use `base-*` colors for majority of the page. Use the default variant for all elements. Use `primary` color once only, for the most important element on the page.
+
+### daisyUI custom theme with custom colors
+A CSS file with Tailwind CSS, daisyUI and a custom daisyUI theme looks like this:
+```css
+@import "tailwindcss";
+@plugin "daisyui";
+@plugin "daisyui/theme" {
+ name: "mytheme";
+ default: true; /* set as default */
+ prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
+ color-scheme: light; /* color of browser-provided UI */
+
+ --color-base-100: oklch(98% 0.02 240);
+ --color-base-200: oklch(95% 0.03 240);
+ --color-base-300: oklch(92% 0.04 240);
+ --color-base-content: oklch(20% 0.05 240);
+ --color-primary: oklch(55% 0.3 240);
+ --color-primary-content: oklch(98% 0.01 240);
+ --color-secondary: oklch(70% 0.25 200);
+ --color-secondary-content: oklch(98% 0.01 200);
+ --color-accent: oklch(65% 0.25 160);
+ --color-accent-content: oklch(98% 0.01 160);
+ --color-neutral: oklch(50% 0.05 240);
+ --color-neutral-content: oklch(98% 0.01 240);
+ --color-info: oklch(70% 0.2 220);
+ --color-info-content: oklch(98% 0.01 220);
+ --color-success: oklch(65% 0.25 140);
+ --color-success-content: oklch(98% 0.01 140);
+ --color-warning: oklch(80% 0.25 80);
+ --color-warning-content: oklch(20% 0.05 80);
+ --color-error: oklch(65% 0.3 30);
+ --color-error-content: oklch(98% 0.01 30);
+
+ --radius-selector: 1rem; /* border radius of selectors (checkbox, toggle, badge) */
+ --radius-field: 0.25rem; /* border radius of fields (button, input, select, tab) */
+ --radius-box: 0.5rem; /* border radius of boxes (card, modal, alert) */
+ /* preferred values for --radius-* : 0rem, 0.25rem, 0.5rem, 1rem, 2rem */
+
+ --size-selector: 0.25rem; /* base size of selectors (checkbox, toggle, badge). Value must be 0.25rem unless we intentionally want bigger selectors. In so it can be 0.28125 or 0.3125. If we intentionally want smaller selectors, it can be 0.21875 or 0.1875 */
+ --size-field: 0.25rem; /* base size of fields (button, input, select, tab). Value must be 0.25rem unless we intentionally want bigger fields. In so it can be 0.28125 or 0.3125. If we intentionally want smaller fields, it can be 0.21875 or 0.1875 */
+
+ --border: 1px; /* border size. Value must be 1px unless we intentionally want thicker borders. In so it can be 1.5px or 2px. If we intentionally want thinner borders, it can be 0.5px */
+
+ --depth: 1; /* only 0 or 1 – Adds a shadow and subtle 3D depth effect to components */
+ --noise: 0; /* only 0 or 1 - Adds a subtle noise (grain) effect to components */
+}
+```
+#### Rules
+- All CSS variables above are required
+- Colors can be OKLCH or hex or other formats
+- If you're generating a custom theme, do not include the comments from the example above. Just provide the code.
+
+People can use https://daisyui.com/theme-generator/ visual tool to create their own theme.
+
+
+### Component discovery protocol
+
+Before writing any daisyUI code, do this in order:
+
+1. Read the request intent, behavior, and shape, not only literal words. Match on meaning.
+2. Use the component list in this file to shortlist the best candidate components.
+3. Read multiple candidate component docs before deciding. Minimum is 3 candidates when there is ambiguity.
+4. Compare each candidate's description, behavior, syntax, and rules against the request.
+5. Select the best component or combination of components and apply their constraints exactly.
+6. State which components were chosen and why they match the request.
+
+Semantic matching is required even when wording differs from component names. A component name might be different from the request but still be the best match. Always consider intent and meaning, not only literal words.
+
+If a user explicitly requests a named component and a same-named doc exists, read that component doc first.
+
+## daisyUI components
+
+### accordion
+Accordion is used for showing and hiding content but only one item can stay open at a time
+
+[accordion docs](https://daisyui.com/components/accordion/)
+
+#### Class names
+- component: `collapse`
+- part: `collapse-title`, `collapse-content`
+- modifier: `collapse-arrow`, `collapse-plus`, `collapse-open`, `collapse-close`
+
+#### Syntax
+```html
+<div class="collapse {MODIFIER}">{CONTENT}</div>
+```
+where content is:
+```html
+<input type="radio" name="{name}" checked="{checked}" />
+<div class="collapse-title">{title}</div>
+<div class="collapse-content">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier class names
+- Accordion uses radio inputs. All radio inputs with the same name work together and only one of them can be open at a time
+- If you have more than one set of accordion items on a page, use different names for the radio inputs on each set
+- Replace {name} with a unique name for the accordion group
+- replace `{checked}` with `checked="checked"` if you want the accordion to be open by default
+
+
+### alert
+Alert informs users about important events
+
+[alert docs](https://daisyui.com/components/alert/)
+
+#### Class names
+- component: `alert`
+- style: `alert-outline`, `alert-dash`, `alert-soft`
+- color: `alert-info`, `alert-success`, `alert-warning`, `alert-error`
+- direction: `alert-vertical`, `alert-horizontal`
+
+#### Syntax
+```html
+<div role="alert" class="alert {MODIFIER}">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each style/color/direction class names
+- Add `sm:alert-horizontal` for responsive layouts
+
+
+### avatar
+Avatars are used to show a thumbnail
+
+[avatar docs](https://daisyui.com/components/avatar/)
+
+#### Class names
+- component: `avatar`, `avatar-group`
+- modifier: `avatar-online`, `avatar-offline`, `avatar-placeholder`
+
+#### Syntax
+```html
+<div class="avatar {MODIFIER}">
+ <div>
+ <img src="{image-url}" />
+ </div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier class names
+- Use `avatar-group` for containing multiple avatars
+- You can set custom sizes using `w-*` and `h-*`
+- You can use mask classes such as `mask-squircle`, `mask-hexagon`, `mask-triangle`
+
+
+### badge
+Badges are used to inform the user of the status of specific data
+
+[badge docs](https://daisyui.com/components/badge/)
+
+#### Class names
+- component: `badge`
+- style: `badge-outline`, `badge-dash`, `badge-soft`, `badge-ghost`
+- color: `badge-neutral`, `badge-primary`, `badge-secondary`, `badge-accent`, `badge-info`, `badge-success`, `badge-warning`, `badge-error`
+- size: `badge-xs`, `badge-sm`, `badge-md`, `badge-lg`, `badge-xl`
+
+#### Syntax
+```html
+<span class="badge {MODIFIER}">Badge</span>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each style/color/size class names
+- Can be used inside text or buttons
+- To create an empty badge, just remove the text between the span tags
+
+
+### breadcrumbs
+Breadcrumbs helps users to navigate
+
+[breadcrumbs docs](https://daisyui.com/components/breadcrumbs/)
+
+#### Class names
+- component: `breadcrumbs`
+
+#### Syntax
+```html
+<div class="breadcrumbs">
+ <ul><li><a>Link</a></li></ul>
+</div>
+```
+
+#### Rules
+- breadcrumbs only has one main class name
+- Can contain icons inside the links
+- If you set `max-width` or the list gets larger than the container it will scroll
+
+
+### button
+Buttons allow the user to take actions
+
+[button docs](https://daisyui.com/components/button/)
+
+#### Class names
+- component: `btn`
+- color: `btn-neutral`, `btn-primary`, `btn-secondary`, `btn-accent`, `btn-info`, `btn-success`, `btn-warning`, `btn-error`
+- style: `btn-outline`, `btn-dash`, `btn-soft`, `btn-ghost`, `btn-link`
+- behavior: `btn-active`, `btn-disabled`
+- size: `btn-xs`, `btn-sm`, `btn-md`, `btn-lg`, `btn-xl`
+- modifier: `btn-wide`, `btn-block`, `btn-square`, `btn-circle`
+
+#### Syntax
+```html
+<button class="btn {MODIFIER}">Button</button>
+```
+#### Rules
+- {MODIFIER} is optional and can have one of each color/style/behavior/size/modifier class names
+- btn can be used on any html tags such as `<button>`, `<a>`, `<input>`
+- btn can have an icon before or after the text
+- set `tabindex="-1" role="button" aria-disabled="true"` if you want to disable the button using a class name
+
+
+### calendar
+Calendar includes styles for different calendar libraries
+
+[calendar docs](https://daisyui.com/components/calendar/)
+
+#### Class names
+- component
+ - `cally (for Cally web component)`
+ - `pika-single (for the input field that opens Pikaday calendar)`
+ - `react-day-picker (for the DayPicker component)`
+
+#### Syntax
+For Cally:
+```html
+<calendar-date class="cally">{CONTENT}</calendar-date>
+```
+For Pikaday:
+```html
+<input type="text" class="input pika-single">
+```
+For React Day Picker:
+```html
+<DayPicker className="react-day-picker">
+```
+
+#### Rules
+- daisyUI supports Cally, Pikaday, React Day Picker
+
+
+### card
+Cards are used to group and display content
+
+[card docs](https://daisyui.com/components/card/)
+
+#### Class names
+- component: `card`
+- part: `card-title`, `card-body`, `card-actions`
+- style: `card-border`, `card-dash`
+- modifier: `card-side`, `image-full`
+- size: `card-xs`, `card-sm`, `card-md`, `card-lg`, `card-xl`
+
+#### Syntax
+```html
+<div class="card {MODIFIER}">
+ <figure><img src="{image-url}" alt="{alt-text}" /></figure>
+ <div class="card-body">
+ <h2 class="card-title">{title}</h2>
+ <p>{CONTENT}</p>
+ <div class="card-actions">{actions}</div>
+ </div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier class names and one of the size class names
+- `<figure>` and `<div class="card-body">` are optional
+- can use `sm:card-horizontal` for responsive layouts
+- If image is placed after `card-body`, the image will be placed at the bottom
+
+
+### carousel
+Carousel show images or content in a scrollable area
+
+[carousel docs](https://daisyui.com/components/carousel/)
+
+#### Class names
+- component: `carousel`
+- part: `carousel-item`
+- modifier: `carousel-start`, `carousel-center`, `carousel-end`
+- direction: `carousel-horizontal`, `carousel-vertical`
+
+#### Syntax
+```html
+<div class="carousel {MODIFIER}">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/direction class names
+- Content is a list of `carousel-item` divs: `<div class="carousel-item"></div>`
+- To create a full-width carousel, add `w-full` to each carousel item
+
+
+### chat
+Chat bubbles are used to show one line of conversation and all its data, including the author image, author name, time, etc
+
+[chat docs](https://daisyui.com/components/chat/)
+
+#### Class names
+- component: `chat`
+- part: `chat-image`, `chat-header`, `chat-footer`, `chat-bubble`
+- placement: `chat-start`, `chat-end`
+- color: `chat-bubble-neutral`, `chat-bubble-primary`, `chat-bubble-secondary`, `chat-bubble-accent`, `chat-bubble-info`, `chat-bubble-success`, `chat-bubble-warning`, `chat-bubble-error`
+
+#### Syntax
+```html
+<div class="chat {PLACEMENT}">
+ <div class="chat-image"></div>
+ <div class="chat-header"></div>
+ <div class="chat-bubble {COLOR}">Message text</div>
+ <div class="chat-footer"></div>
+</div>
+```
+
+#### Rules
+- {PLACEMENT} is required and must be either `chat-start` or `chat-end`
+- {COLOR} is optional and can have one of the color class names
+- To add an avatar, use `<div class="chat-image avatar">` and nest the avatar content inside
+
+
+### checkbox
+Checkboxes are used to select or deselect a value
+
+[checkbox docs](https://daisyui.com/components/checkbox/)
+
+#### Class names
+- component: `checkbox`
+- color: `checkbox-primary`, `checkbox-secondary`, `checkbox-accent`, `checkbox-neutral`, `checkbox-success`, `checkbox-warning`, `checkbox-info`, `checkbox-error`
+- size: `checkbox-xs`, `checkbox-sm`, `checkbox-md`, `checkbox-lg`, `checkbox-xl`
+
+#### Syntax
+```html
+<input type="checkbox" class="checkbox {MODIFIER}" />
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each color/size class names
+
+
+### collapse
+Collapse is used for showing and hiding content
+
+[collapse docs](https://daisyui.com/components/collapse/)
+
+#### Class names
+- component: `collapse`
+- part: `collapse-title`, `collapse-content`
+- modifier: `collapse-arrow`, `collapse-plus`, `collapse-open`, `collapse-close`
+
+#### Syntax
+```html
+<div tabindex="0" class="collapse {MODIFIER}">
+ <div class="collapse-title">{title}</div>
+ <div class="collapse-content">{CONTENT}</div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier class names
+- instead of `tabindex="0"`, you can use `<input type="checkbox">` as a first child
+- Can also be a details/summary tag
+
+
+### countdown
+Countdown gives you a transition effect when you change a number between 0 to 999
+
+[countdown docs](https://daisyui.com/components/countdown/)
+
+#### Class names
+- component: `countdown`
+
+#### Syntax
+```html
+<span class="countdown">
+ <span style="--value:{number};">number</span>
+</span>
+```
+
+#### Rules
+- The `--value` CSS variable and text must be a number between 0 and 999
+- you need to change the span text and the `--value` CSS variable using JS
+- you need to add `aria-live="polite"` and `aria-label="{number}"` so screen readers can properly read changes
+
+
+### diff
+Diff component shows a side-by-side comparison of two items
+
+[diff docs](https://daisyui.com/components/diff/)
+
+#### Class names
+- component: `diff`
+- part: `diff-item-1`, `diff-item-2`, `diff-resizer`
+
+#### Syntax
+```html
+<figure class="diff">
+ <div class="diff-item-1">{item1}</div>
+ <div class="diff-item-2">{item2}</div>
+ <div class="diff-resizer"></div>
+</figure>
+```
+
+#### Rules
+- To maintain aspect ratio, add `aspect-16/9` or other aspect ratio classes to `<figure class="diff">` element
+
+
+### divider
+Divider will be used to separate content vertically or horizontally
+
+[divider docs](https://daisyui.com/components/divider/)
+
+#### Class names
+- component: `divider`
+- color: `divider-neutral`, `divider-primary`, `divider-secondary`, `divider-accent`, `divider-success`, `divider-warning`, `divider-info`, `divider-error`
+- direction: `divider-vertical`, `divider-horizontal`
+- placement: `divider-start`, `divider-end`
+
+#### Syntax
+```html
+<div class="divider {MODIFIER}">{text}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each direction/color/placement class names
+- Omit text for a blank divider
+
+
+### dock
+Dock (also know as Bottom navigation or Bottom bar) is a UI element that provides navigation options to the user. Dock sticks to the bottom of the screen
+
+[dock docs](https://daisyui.com/components/dock/)
+
+#### Class names
+- component: `dock`
+- part: `dock-label`
+- modifier: `dock-active`
+- size: `dock-xs`, `dock-sm`, `dock-md`, `dock-lg`, `dock-xl`
+
+#### Syntax
+```html
+<div class="dock {MODIFIER}">{CONTENT}</div>
+```
+where content is a list of buttons:
+```html
+<button>
+ <svg>{icon}</svg>
+ <span class="dock-label">Text</span>
+</button>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the size class names
+- To make a button active, add `dock-active` class to the button
+- add `<meta name="viewport" content="viewport-fit=cover">` is required for responsivness of the dock in iOS
+
+
+### drawer
+Drawer is a grid layout that can show/hide a sidebar on the left or right side of the page
+
+[drawer docs](https://daisyui.com/components/drawer/)
+
+#### Class names
+- component: `drawer`
+- part: `drawer-toggle`, `drawer-content`, `drawer-side`, `drawer-overlay`
+- placement: `drawer-end`
+- modifier: `drawer-open`
+- variant: `is-drawer-open:`, `is-drawer-close:`
+
+#### Syntax
+```html
+<div class="drawer {MODIFIER}">
+ <input id="my-drawer" type="checkbox" class="drawer-toggle" />
+ <div class="drawer-content">{CONTENT}</div>
+ <div class="drawer-side">{SIDEBAR}</div>
+</div>
+```
+where {CONTENT} can be navbar, site content, footer, etc
+and {SIDEBAR} can be a menu like:
+```html
+<ul class="menu p-4 w-80 min-h-full bg-base-100 text-base-content">
+ <li><a>Item 1</a></li>
+ <li><a>Item 2</a></li>
+</ul>
+```
+To open/close the drawer, use a label that points to the `drawer-toggle` input:
+```html
+<label for="my-drawer" class="btn drawer-button">Open/close drawer</label>
+```
+Example: This sidebar is always visible on large screen, can be toggled on small screen:
+```html
+<div class="drawer lg:drawer-open">
+ <input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
+ <div class="drawer-content flex flex-col items-center justify-center">
+ <!-- Page content here -->
+ <label for="my-drawer-3" class="btn drawer-button lg:hidden">
+ Open drawer
+ </label>
+ </div>
+ <div class="drawer-side">
+ <label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label>
+ <ul class="menu bg-base-200 min-h-full w-80 p-4">
+ <!-- Sidebar content here -->
+ <li><button>Sidebar Item 1</button></li>
+ <li><button>Sidebar Item 2</button></li>
+ </ul>
+ </div>
+</div>
+```
+
+Example: This sidebar is always visible. When it's close we only see iocns, when it's open we see icons and text
+```html
+<div class="drawer lg:drawer-open">
+ <input id="my-drawer-4" type="checkbox" class="drawer-toggle" />
+ <div class="drawer-content">
+ <!-- Page content here -->
+ </div>
+ <div class="drawer-side is-drawer-close:overflow-visible">
+ <label for="my-drawer-4" aria-label="close sidebar" class="drawer-overlay"></label>
+ <div class="is-drawer-close:w-14 is-drawer-open:w-64 bg-base-200 flex flex-col items-start min-h-full">
+ <!-- Sidebar content here -->
+ <ul class="menu w-full grow">
+ <!-- list item -->
+ <li>
+ <button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Homepage">
+ 🏠
+ <span class="is-drawer-close:hidden">Homepage</span>
+ </button>
+ </li>
+ <!-- list item -->
+ <li>
+ <button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
+ 🔧
+ <span class="is-drawer-close:hidden">Settings</span>
+ </button>
+ </li>
+ </ul>
+ <!-- button to open/close drawer -->
+ <div class="m-2 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open">
+ <label for="my-drawer-4" class="btn btn-ghost btn-circle drawer-button is-drawer-open:rotate-y-180">
+ 🔄
+ </label>
+ </div>
+ </div>
+ </div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/placement class names
+- `id` is required for the `drawer-toggle` input. change `my-drawer` to a unique id according to your needs
+- `lg:drawer-open` can be used to make sidebar visible on larger screens
+- `drawer-toggle` is a hidden checkbox. Use label with "for" attribute to toggle state
+- if you want to open the drawer when a button is clicked, use `<label for="my-drawer" class="btn drawer-button">Open drawer</label>` where `my-drawer` is the id of the `drawer-toggle` input
+- when using drawer, every page content must be inside `drawer-content` element. for example navbar, footer, etc should not be outside of `drawer`
+
+
+### dropdown
+Dropdown can open a menu or any other element when the button is clicked
+
+[dropdown docs](https://daisyui.com/components/dropdown/)
+
+#### Class names
+- component: `dropdown`
+- part: `dropdown-content`
+- placement: `dropdown-start`, `dropdown-center`, `dropdown-end`, `dropdown-top`, `dropdown-bottom`, `dropdown-left`, `dropdown-right`
+- modifier: `dropdown-hover`, `dropdown-open`, `dropdown-close`
+
+#### Syntax
+Using details and summary
+```html
+<details class="dropdown">
+ <summary>Button</summary>
+ <ul class="dropdown-content">{CONTENT}</ul>
+</details>
+```
+
+Using popover API
+```html
+<button popovertarget="{id}" style="anchor-name:--{anchor}">{button}</button>
+<ul class="dropdown-content" popover id="{id}" style="position-anchor:--{anchor}">{CONTENT}</ul>
+```
+
+Using CSS focus
+```html
+<div class="dropdown">
+ <div tabindex="0" role="button">Button</div>
+ <ul tabindex="-1" class="dropdown-content">{CONTENT}</ul>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/placement class names
+- replace `{id}` and `{anchor}` with a unique name
+- For CSS focus dropdowns, use `tabindex="0"` and `role="button"` on the button
+- The content can be any HTML element (not just `<ul>`)
+
+
+### fab
+FAB (Floating Action Button) stays in the bottom corner of screen. It includes a focusable and accessible element with button role. Clicking or focusing it shows additional buttons (known as Speed Dial buttons) in a vertical arrangement or a flower shape (quarter circle)
+
+[fab docs](https://daisyui.com/components/fab/)
+
+#### Class names
+- component: `fab`
+- part: `fab-close`, `fab-main-action`
+- modifier: `fab-flower`
+
+#### Syntax
+A single FAB in the corder of screen
+```html
+<div class="fab">
+ <button class="btn btn-lg btn-circle">{IconOriginal}</button>
+</div>
+```
+A FAB that opens a 3 other buttons in the corner of page vertically
+```html
+<div class="fab">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <button class="btn btn-lg btn-circle">{Icon1}</button>
+ <button class="btn btn-lg btn-circle">{Icon2}</button>
+ <button class="btn btn-lg btn-circle">{Icon3}</button>
+</div>
+```
+A FAB that opens a 3 other buttons in the corner of page vertically and they have label text
+```html
+<div class="fab">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <div>{Label1}<button class="btn btn-lg btn-circle">{Icon1}</button></div>
+ <div>{Label2}<button class="btn btn-lg btn-circle">{Icon2}</button></div>
+ <div>{Label3}<button class="btn btn-lg btn-circle">{Icon3}</button></div>
+</div>
+```
+FAB with rectangle buttons. These are not circular buttons so they can have more content.
+```html
+<div class="fab">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <button class="btn btn-lg">{Label1}</button>
+ <button class="btn btn-lg">{Label2}</button>
+ <button class="btn btn-lg">{Label3}</button>
+</div>
+```
+FAB with close button. When FAB is open, the original button is replaced with a close button
+```html
+<div class="fab">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <div class="fab-close">Close <span class="btn btn-circle btn-lg btn-error">✕</span></div>
+ <div>{Label1}<button class="btn btn-lg btn-circle">{Icon1}</button></div>
+ <div>{Label2}<button class="btn btn-lg btn-circle">{Icon2}</button></div>
+ <div>{Label3}<button class="btn btn-lg btn-circle">{Icon3}</button></div>
+</div>
+```
+FAB with Main Action button. When FAB is open, the original button is replaced with a main action button
+```html
+<div class="fab">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <div class="fab-main-action">
+ {LabelMainAction}<button class="btn btn-circle btn-secondary btn-lg">{IconMainAction}</button>
+ </div>
+ <div>{Label1}<button class="btn btn-lg btn-circle">{Icon1}</button></div>
+ <div>{Label2}<button class="btn btn-lg btn-circle">{Icon2}</button></div>
+ <div>{Label3}<button class="btn btn-lg btn-circle">{Icon3}</button></div>
+</div>
+```
+FAB Flower. It opens the buttons in a flower shape (quarter circle) arrangement instead of vertical
+```html
+<div class="fab fab-flower">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <button class="fab-main-action btn btn-circle btn-lg">{IconMainAction}</button>
+ <button class="btn btn-lg btn-circle">{Icon1}</button>
+ <button class="btn btn-lg btn-circle">{Icon2}</button>
+ <button class="btn btn-lg btn-circle">{Icon3}</button>
+</div>
+```
+FAB Flower with tooltips. There's no space for a text label in a quarter circle, so tooltips are used to indicate the button's function
+```html
+<div class="fab fab-flower">
+ <div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">{IconOriginal}</div>
+ <button class="fab-main-action btn btn-circle btn-lg">{IconMainAction}</button>
+ <div class="tooltip tooltip-left" data-tip="{Label1}">
+ <button class="btn btn-lg btn-circle">{Icon1}</button>
+ </div>
+ <div class="tooltip tooltip-left" data-tip="{Label2}">
+ <button class="btn btn-lg btn-circle">{Icon2}</button>
+ </div>
+ <div class="tooltip tooltip-left" data-tip="{Label3}">
+ <button class="btn btn-lg btn-circle">{Icon3}</button>
+ </div>
+</div>
+```
+#### Rules
+- {Icon*} should be replaced with the appropriate icon for each button. SVG icons are recommended
+- {IconOriginal} is the icon that we see before opening the FAB
+- {IconMainAction} is the icon we see after opening the FAB
+- {Icon1}, {Icon2}, {Icon3} are the icons for the additional buttons
+- {Label*} is the label text for each button
+
+
+### fieldset
+Fieldset is a container for grouping related form elements. It includes fieldset-legend as a title and label as a description
+
+[fieldset docs](https://daisyui.com/components/fieldset/)
+
+#### Class names
+- Component: `fieldset`, `label`
+- Parts: `fieldset-legend`
+
+#### Syntax
+```html
+<fieldset class="fieldset">
+ <legend class="fieldset-legend">{title}</legend>
+ {CONTENT}
+ <p class="label">{description}</p>
+</fieldset>
+```
+
+#### Rules
+- You can use any element as a direct child of fieldset to add form elements
+
+
+### file-input
+File Input is a an input field for uploading files
+
+[file-input docs](https://daisyui.com/components/file-input/)
+
+#### Class Names:
+- Component: `file-input`
+- Style: `file-input-ghost`
+- Color: `file-input-neutral`, `file-input-primary`, `file-input-secondary`, `file-input-accent`, `file-input-info`, `file-input-success`, `file-input-warning`, `file-input-error`
+- Size: `file-input-xs`, `file-input-sm`, `file-input-md`, `file-input-lg`, `file-input-xl`
+
+#### Syntax
+```html
+<input type="file" class="file-input {MODIFIER}" />
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each style/color/size class names
+
+
+### filter
+Filter is a group of radio buttons. Choosing one of the options will hide the others and shows a reset button next to the chosen option
+
+[filter docs](https://daisyui.com/components/filter/)
+
+#### Class names
+- component: `filter`
+- part: `filter-reset`
+
+#### Syntax
+Using HTML form
+```html
+<form class="filter">
+ <input class="btn btn-square" type="reset" value="×"/>
+ <input class="btn" type="radio" name="{NAME}" aria-label="Tab 1 title"/>
+ <input class="btn" type="radio" name="{NAME}" aria-label="Tab 2 title"/>
+</form>
+```
+Without HTML form
+```html
+<div class="filter">
+ <input class="btn filter-reset" type="radio" name="{NAME}" aria-label="×"/>
+ <input class="btn" type="radio" name="{NAME}" aria-label="Tab 1 title"/>
+ <input class="btn" type="radio" name="{NAME}" aria-label="Tab 2 title"/>
+</div>
+```
+
+#### Rules
+- replace `{NAME}` with proper value, according to the context of the filter
+- Each set of radio inputs must have unique `name` attributes to avoid conflicts
+- Use `<form>` tag when possible and only use `<div>` if you can't use a HTML form for some reason
+- Use `filter-reset` class for the reset button
+- Do not check any of the radio inputs by default
+
+
+### footer
+Footer can contain logo, copyright notice, and links to other pages
+
+[footer docs](https://daisyui.com/components/footer/)
+
+#### Class names
+- component: `footer`
+- part: `footer-title`
+- placement: `footer-center`
+- direction: `footer-horizontal`, `footer-vertical`
+
+#### Syntax
+```html
+<footer class="footer {MODIFIER}">{CONTENT}</footer>
+```
+where content can contain several `<nav>` tags with `footer-title` and links inside
+
+#### Rules
+- {MODIFIER} is optional and can have one of each placement/direction class names
+- try to use `sm:footer-horizontal` to make footer responsive
+- suggestion - use `base-200` for background color
+
+
+### hero
+Hero is a component for displaying a large box or image with a title and description
+
+[hero docs](https://daisyui.com/components/hero/)
+
+#### Class names
+- component: `hero`
+- part: `hero-content`, `hero-overlay`
+
+#### Syntax
+```html
+<div class="hero {MODIFIER}">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional
+- Use `hero-content` for the text content
+- Use `hero-overlay` inside the hero to overlay the background image with a color
+- Content can contain a figure
+
+
+### hover-3d
+Hover 3D is a wrapper component that adds a 3D hover effect to its content. When we hover over the component, it tilts and rotates based on the mouse position, creating an interactive 3D effect.
+
+`hover-3d` works by placing 8 hover zones on top of the content. Each zone detects mouse movement and applies a slight rotation to the content based on the mouse position within that zone. The combined effect of all 8 zones creates a smooth and responsive 3D tilt effect as the user moves their mouse over the component.
+
+Only use non-interactive content inside the `hover-3d` wrapper. If you want to make the entire card clickable, use a link for the whole `hover-3d` component instead of putting interactive elements like buttons or links inside it.
+
+[hover-3d docs](https://daisyui.com/components/hover-3d/)
+
+#### Class names
+- component: `hover-3d`
+
+#### Syntax
+```html
+<div class="hover-3d my-12 mx-2">
+ <figure class="max-w-100 rounded-2xl">
+ <img src="https://img.daisyui.com/images/stock/creditcard.webp" alt="Tailwind CSS 3D card" />
+ </figure>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+</div>
+```
+
+#### Rules
+- hover-3d can be a `<div>` or a `<a>`
+- hover-3d must have exactly 9 direct children where the first child is the main content and the other 8 children are empty `<div>`s for hover zones
+- content inside hover-3d should be non-interactive (no buttons, links, inputs, etc)
+
+
+### hover-gallery
+Hover Gallery is container of images. The first image is visible be default and when we hover it horizontally, other images show up. Hover Gallery is useful for product cards in ecommerce sites, portfoilios or in image galleries. Hover Gallery can include up to 10 images.
+
+[hover-gallery docs](https://daisyui.com/components/hover-gallery/)
+
+#### Class names
+- component: `hover-gallery`
+
+#### Syntax
+```html
+<figure class="hover-gallery max-w-60">
+ <img src="https://img.daisyui.com/images/stock/daisyui-hat-1.webp" />
+ <img src="https://img.daisyui.com/images/stock/daisyui-hat-2.webp" />
+ <img src="https://img.daisyui.com/images/stock/daisyui-hat-3.webp" />
+ <img src="https://img.daisyui.com/images/stock/daisyui-hat-4.webp" />
+</figure>
+```
+
+#### Rules
+- hover-gallery can be a `<div>` or a `<figure>`
+- hover-gallery can include up to 10 images
+- hover-gallery needs a max width otherwise if fills the container width
+- images must be same dimensions for a proper alignment
+
+
+### indicator
+Indicators are used to place an element on the corner of another element
+
+[indicator docs](https://daisyui.com/components/indicator/)
+
+#### Class names
+- component: `indicator`
+- part: `indicator-item`
+- placement: `indicator-start`, `indicator-center`, `indicator-end`, `indicator-top`, `indicator-middle`, `indicator-bottom`
+
+#### Syntax
+```html
+<div class="indicator">
+ <span class="indicator-item">{indicator content}</span>
+ <div>{main content}</div>
+</div>
+```
+
+#### Rules
+- Add all indicator elements (with `indicator-item` class) before the main content
+- {placement} is optional and can have one of each horizontal/vertical class names. default is `indicator-end indicator-top`
+
+
+### input
+Text Input is a simple input field
+
+[input docs](https://daisyui.com/components/input/)
+
+#### Class names
+- component: `input`
+- style: `input-ghost`
+- color: `input-neutral`, `input-primary`, `input-secondary`, `input-accent`, `input-info`, `input-success`, `input-warning`, `input-error`
+- size: `input-xs`, `input-sm`, `input-md`, `input-lg`, `input-xl`
+
+#### Syntax
+```html
+<input type="{type}" placeholder="Type here" class="input {MODIFIER}" />
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each style/color/size class names
+- Can be used with any input field type (text, password, email, etc.)
+- Use `input` class for the parent when you have more than one element inside input
+
+
+### join
+Join is a container for grouping multiple items, it can be used to group buttons, inputs, etc. Join applies border radius to the first and last item. Join can be used to create a horizontal or vertical list of items
+
+[join docs](https://daisyui.com/components/join/)
+
+#### Class names
+- component: `join`, `join-item`
+- direction: `join-vertical`, `join-horizontal`
+
+#### Syntax
+```html
+<div class="join {MODIFIER}">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the direction class names
+- Any direct child of the join element will get joined together
+- Any element with `join-item` will be affected
+- Use `lg:join-horizontal` for responsive layouts
+
+
+### kbd
+Kbd is used to display keyboard shortcuts
+
+[kbd docs](https://daisyui.com/components/kbd/)
+
+#### Class names
+- component: `kbd`
+- size: `kbd-xs`, `kbd-sm`, `kbd-md`, `kbd-lg`, `kbd-xl`
+
+#### Syntax
+```html
+<kbd class="kbd {MODIFIER}">K</kbd>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the size class names
+
+
+### label
+Label is used to provide a name or title for an input field. Label can be placed before or after the field
+
+[label docs](https://daisyui.com/components/label/)
+
+#### Class names
+- component: `label`, `floating-label`
+
+#### Syntax
+For regular label:
+```html
+<label class="input">
+ <span class="label">{label text}</span>
+ <input type="text" placeholder="Type here" />
+</label>
+```
+For floating label:
+```html
+<label class="floating-label">
+ <input type="text" placeholder="Type here" class="input" />
+ <span>{label text}</span>
+</label>
+```
+
+#### Rules
+- The `input` class is for styling the parent element which contains the input field and label, so the label does not have the 'input' class
+- Use `floating-label` for the parent of an input field and a span that floats above the input field when the field is focused
+
+
+### link
+Link adds the missing underline style to links
+
+[link docs](https://daisyui.com/components/link/)
+
+#### Class names
+- component: `link`
+- style: `link-hover`
+- color: `link-neutral`, `link-primary`, `link-secondary`, `link-accent`, `link-success`, `link-info`, `link-warning`, `link-error`
+
+#### Syntax
+```html
+<a class="link {MODIFIER}">Click me</a>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier class names
+
+
+### list
+List is a vertical layout to display information in rows
+
+[list docs](https://daisyui.com/components/list/)
+
+#### Class Names:
+- Component: `list`, `list-row`
+- Modifier: `list-col-wrap`, `list-col-grow`
+
+#### Syntax
+```html
+<ul class="list">
+ <li class="list-row">{CONTENT}</li>
+</ul>
+```
+
+#### Rules
+- Use `list-row` for each item inside the list
+- By default, the second child of the `list-row` will fill the remaining space. You can use `list-col-grow` on another child to make it fill the remaining space instead
+- Use `list-col-wrap` to force an item to wrap to the next line
+
+
+### loading
+Loading shows an animation to indicate that something is loading
+
+[loading docs](https://daisyui.com/components/loading/)
+
+#### Class names
+- component: `loading`
+- style: `loading-spinner`, `loading-dots`, `loading-ring`, `loading-ball`, `loading-bars`, `loading-infinity`
+- size: `loading-xs`, `loading-sm`, `loading-md`, `loading-lg`, `loading-xl`
+
+#### Syntax
+```html
+<span class="loading {MODIFIER}"></span>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the style/size class names
+
+
+### mask
+Mask crops the content of the element to common shapes
+
+[mask docs](https://daisyui.com/components/mask/)
+
+#### Class names
+- component: `mask`
+- style: `mask-squircle`, `mask-heart`, `mask-hexagon`, `mask-hexagon-2`, `mask-decagon`, `mask-pentagon`, `mask-diamond`, `mask-square`, `mask-circle`, `mask-star`, `mask-star-2`, `mask-triangle`, `mask-triangle-2`, `mask-triangle-3`, `mask-triangle-4`
+- modifier: `mask-half-1`, `mask-half-2`
+
+#### Syntax
+```html
+<img class="mask {MODIFIER}" src="{image-url}" />
+```
+
+#### Rules
+- {MODIFIER} is required and can have one of the style/modifier class names
+- You can change the shape of any element using `mask` class names
+- You can set custom sizes using `w-*` and `h-*`
+
+
+### menu
+Menu is used to display a list of links vertically or horizontally
+
+[menu docs](https://daisyui.com/components/menu/)
+
+#### Class names
+- component: `menu`
+- part: `menu-title`, `menu-dropdown`, `menu-dropdown-toggle`
+- modifier: `menu-disabled`, `menu-active`, `menu-focus`, `menu-dropdown-show`
+- size: `menu-xs`, `menu-sm`, `menu-md`, `menu-lg`, `menu-xl`
+- direction: `menu-vertical`, `menu-horizontal`
+
+#### Syntax
+Vertical menu:
+```html
+<ul class="menu">
+ <li><button>Item</button></li>
+</ul>
+```
+Horizontal menu:
+```html
+<ul class="menu menu-horizontal">
+ <li><button>Item</button></li>
+</ul>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/size/direction class names
+- Use `lg:menu-horizontal` for responsive layouts
+- Use `menu-title` for list item title
+- Use `<details>` tag to make submenus collapsible
+- Use `menu-dropdown` and `menu-dropdown-toggle` to toggle the dropdown using JS
+
+
+### mockup-browser
+Browser mockup shows a box that looks like a browser window
+
+[mockup-browser docs](https://daisyui.com/components/mockup-browser/)
+
+#### Class names
+- component: `mockup-browser`
+- part: `mockup-browser-toolbar`
+
+#### Syntax
+```html
+<div class="mockup-browser">
+ <div class="mockup-browser-toolbar">
+ {toolbar content}
+ </div>
+ <div>{CONTENT}</div>
+</div>
+```
+
+#### Rules
+- For a default mockup, use just `mockup-browser` class name
+- To set a URL in toolbar, add a div with `input` class
+
+
+### mockup-code
+Code mockup is used to show a block of code in a box that looks like a code editor
+
+[mockup-code docs](https://daisyui.com/components/mockup-code/)
+
+#### Class names
+- component: `mockup-code`
+
+#### Syntax
+```html
+<div class="mockup-code">
+ <pre data-prefix="$"><code>npm i daisyui</code></pre>
+</div>
+```
+
+#### Rules
+- Use `<pre data-prefix="{prefix}">` to show a prefix before each line
+- Use `<code>` tag to add code syntax highlighting (requires additional library)
+- To highlight a line, add background/text color
+
+
+### mockup-phone
+Phone mockup shows a mockup of an iPhone
+
+[mockup-phone docs](https://daisyui.com/components/mockup-phone/)
+
+#### Class names
+- component: `mockup-phone`
+- part: `mockup-phone-camera`, `mockup-phone-display`
+
+#### Syntax
+```html
+<div class="mockup-phone">
+ <div class="mockup-phone-camera"></div>
+ <div class="mockup-phone-display">{CONTENT}</div>
+</div>
+```
+
+#### Rules
+- Inside `mockup-phone-display` you can add anything
+
+
+### mockup-window
+Window mockup shows a box that looks like an operating system window
+
+[mockup-window docs](https://daisyui.com/components/mockup-window/)
+
+#### Class names
+- component: `mockup-window`
+
+#### Syntax
+```html
+<div class="mockup-window">
+ <div>{CONTENT}</div>
+</div>
+```
+
+
+### modal
+Modal is used to show a dialog or a box when you click a button
+
+[modal docs](https://daisyui.com/components/modal/)
+
+#### Class names
+- component: `modal`
+- part: `modal-box`, `modal-action`, `modal-backdrop`, `modal-toggle`
+- modifier: `modal-open`
+- placement: `modal-top`, `modal-middle`, `modal-bottom`, `modal-start`, `modal-end`
+
+#### Syntax
+Using HTML dialog element
+```html
+<button onclick="my_modal.showModal()">Open modal</button>
+<dialog id="my_modal" class="modal">
+ <div class="modal-box">{CONTENT}</div>
+ <form method="dialog" class="modal-backdrop"><button>close</button></form>
+</dialog>
+```
+
+Using checkbox (legacy)
+```html
+<label for="my-modal" class="btn">Open modal</label>
+<input type="checkbox" id="my-modal" class="modal-toggle" />
+<div class="modal">
+ <div class="modal-box">{CONTENT}</div>
+ <label class="modal-backdrop" for="my-modal">Close</label>
+</div>
+```
+
+Using anchor links (legacy)
+```html
+<a href="#my-modal" class="btn">Open modal</a>
+<div class="modal" id="my-modal">
+ <div class="modal-box">{CONTENT}</div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/placement class names
+- Add `tabindex="0"` to make modal focusable
+- Use unique IDs for each modal
+- For HTML dialog element modals, add `<form method="dialog">` for closing the modal with submit
+
+
+### navbar
+Navbar is used to show a navigation bar on the top of the page
+
+[navbar docs](https://daisyui.com/components/navbar/)
+
+#### Class names
+- component: `navbar`
+- part: `navbar-start`, `navbar-center`, `navbar-end`
+
+#### Syntax
+```html
+<div class="navbar">{CONTENT}</div>
+```
+
+#### Rules
+- use `navbar-start`, `navbar-center`, `navbar-end` to position content horizontally
+- put anything inside each section
+- suggestion - use `base-200` for background color
+
+
+### pagination
+Pagination is a group of buttons
+
+[pagination docs](https://daisyui.com/components/pagination/)
+
+#### Class names
+- component: `join`
+- part: `join-item`
+- direction: `join-vertical`, `join-horizontal`
+
+#### Syntax
+```html
+<div class="join">{CONTENT}</div>
+```
+
+#### Rules
+- Use `join-item` for each button or link inside the pagination
+- Use `btn` class for styling pagination items
+
+
+### progress
+Progress bar can be used to show the progress of a task or to show the passing of time
+
+[progress docs](https://daisyui.com/components/progress/)
+
+#### Class names
+- component: `progress`
+- color: `progress-neutral`, `progress-primary`, `progress-secondary`, `progress-accent`, `progress-info`, `progress-success`, `progress-warning`, `progress-error`
+
+#### Syntax
+```html
+<progress class="progress {MODIFIER}" value="50" max="100"></progress>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the color class names
+- You must specify value and max attributes
+
+
+### radial-progress
+Radial progress can be used to show the progress of a task or to show the passing of time
+
+[radial-progress docs](https://daisyui.com/components/radial-progress/)
+
+#### Class names
+- component: `radial-progress`
+
+#### Syntax
+```html
+<div class="radial-progress" style="--value:70;" aria-valuenow="70" role="progressbar">70%</div>
+```
+
+#### Rules
+- The `--value` CSS variable and text must be a number between 0 and 100
+- you need to add `aria-valuenow="{value}"`, `aria-valuenow={value}` so screen readers can properly read value and also show that its a progress element to them
+- Use `div` instead of progress because browsers can't show text inside progress tag
+- Use `--size` for setting size (default 5rem) and `--thickness` to set how thick the indicator is
+
+
+### radio
+Radio buttons allow the user to select one option
+
+[radio docs](https://daisyui.com/components/radio/)
+
+#### Class names
+- component: `radio`
+- color: `radio-neutral`, `radio-primary`, `radio-secondary`, `radio-accent`, `radio-success`, `radio-warning`, `radio-info`, `radio-error`
+- size: `radio-xs`, `radio-sm`, `radio-md`, `radio-lg`, `radio-xl`
+
+#### Syntax
+```html
+<input type="radio" name="{name}" class="radio {MODIFIER}" />
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the size/color class names
+- Replace {name} with a unique name for the radio group
+- Each set of radio inputs should have unique `name` attributes to avoid conflicts with other sets of radio inputs on the same page
+
+
+### range
+Range slider is used to select a value by sliding a handle
+
+[range docs](https://daisyui.com/components/range/)
+
+#### Class names
+- component: `range`
+- color: `range-neutral`, `range-primary`, `range-secondary`, `range-accent`, `range-success`, `range-warning`, `range-info`, `range-error`
+- size: `range-xs`, `range-sm`, `range-md`, `range-lg`, `range-xl`
+
+#### Syntax
+```html
+<input type="range" min="0" max="100" value="40" class="range {MODIFIER}" />
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each color/size class names
+- You must specify `min` and `max` attributes
+
+
+### rating
+Rating is a set of radio buttons that allow the user to rate something
+
+[rating docs](https://daisyui.com/components/rating/)
+
+#### Class names
+- component: `rating`
+- modifier: `rating-half`, `rating-hidden`
+- size: `rating-xs`, `rating-sm`, `rating-md`, `rating-lg`, `rating-xl`
+
+#### Syntax
+```html
+<div class="rating {MODIFIER}">
+ <input type="radio" name="rating-1" class="mask mask-star" />
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/size class names
+- Each set of rating inputs should have unique `name` attributes to avoid conflicts with other ratings on the same page
+- Add `rating-hidden` for the first radio to make it hidden so user can clear the rating
+
+
+### select
+Select is used to pick a value from a list of options
+
+[select docs](https://daisyui.com/components/select/)
+
+#### Class names
+- component: `select`
+- style: `select-ghost`
+- color: `select-neutral`, `select-primary`, `select-secondary`, `select-accent`, `select-info`, `select-success`, `select-warning`, `select-error`
+- size: `select-xs`, `select-sm`, `select-md`, `select-lg`, `select-xl`
+
+#### Syntax
+```html
+<select class="select {MODIFIER}">
+ <option>Option</option>
+</select>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each style/color/size class names
+
+
+### skeleton
+Skeleton is a component that can be used to show a loading state
+
+[skeleton docs](https://daisyui.com/components/skeleton/)
+
+#### Class names
+- component: `skeleton`
+- modifier: `skeleton-text`
+
+#### Syntax
+```html
+<div class="skeleton"></div>
+```
+Example with text skeleton:
+```html
+<div class="skeleton skeleton-text">Loading data...</div>
+```
+
+#### Rules
+- Add `h-*` and `w-*` utility classes to set height and width
+
+
+### stack
+Stack visually puts elements on top of each other
+
+[stack docs](https://daisyui.com/components/stack/)
+
+#### Class Names:
+- Component: `stack`
+- Modifier: `stack-top`, `stack-bottom`, `stack-start`, `stack-end`
+
+#### Syntax
+```html
+<div class="stack {MODIFIER}">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier class names
+- You can use `w-*` and `h-*` classes to set the width and height of the stack, making all items the same size
+
+
+### stat
+Stat is used to show numbers and data in a block
+
+[stat docs](https://daisyui.com/components/stat/)
+
+#### Class names
+- Component: `stats`
+- Part: `stat`, `stat-title`, `stat-value`, `stat-desc`, `stat-figure`, `stat-actions`
+- Direction: `stats-horizontal`, `stats-vertical`
+
+#### Syntax
+```html
+<div class="stats {MODIFIER}">
+ <div class="stat">{CONTENT}</div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the direction class names
+- It's horizontal by default but you can make it vertical with the `stats-vertical` class
+- Content includes `stat-title`, `stat-value`, `stat-desc` inside a `stat`
+
+
+### status
+Status is a really small icon to visually show the current status of an element, like online, offline, error, etc
+
+[status docs](https://daisyui.com/components/status/)
+
+#### Class Names:
+- Component: `status`
+- Color: `status-neutral`, `status-primary`, `status-secondary`, `status-accent`, `status-info`, `status-success`, `status-warning`, `status-error`
+- Size: `status-xs`, `status-sm`, `status-md`, `status-lg`, `status-xl`
+
+#### Syntax
+```html
+<span class="status {MODIFIER}"></span>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the color/size class names
+- This component does not render anything visible
+
+
+### steps
+Steps can be used to show a list of steps in a process
+
+[steps docs](https://daisyui.com/components/steps/)
+
+#### Class Names:
+- Component: `steps`
+- Part: `step`, `step-icon`
+- Color: `step-neutral`, `step-primary`, `step-secondary`, `step-accent`, `step-info`, `step-success`, `step-warning`, `step-error`
+- Direction: `steps-vertical`, `steps-horizontal`
+
+#### Syntax
+```html
+<ul class="steps {MODIFIER}">
+ <li class="step">{step content}</li>
+</ul>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each direction/color class names
+- To make a step active, add the `step-primary` class
+- You can add an icon in each step using `step-icon` class
+- To display data in `data-content` ,use `data-content="{value}"` at the `<li>`
+
+
+### swap
+Swap allows you to toggle the visibility of two elements using a checkbox or a class name
+
+[swap docs](https://daisyui.com/components/swap/)
+
+#### Class Names:
+- Component: `swap`
+- Part: `swap-on`, `swap-off`, `swap-indeterminate`
+- Modifier: `swap-active`
+- Style: `swap-rotate`, `swap-flip`
+
+#### Syntax
+Using checkbox
+```html
+<label class="swap {MODIFIER}">
+ <input type="checkbox" />
+ <div class="swap-on">{content when active}</div>
+ <div class="swap-off">{content when inactive}</div>
+</label>
+```
+
+Using class name
+```html
+<div class="swap {MODIFIER}">
+ <div class="swap-on">{content when active}</div>
+ <div class="swap-off">{content when inactive}</div>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/style class names
+- Use only a hidden checkbox to control swap state or add/remove the `swap-active` class using JS to control state
+- To show something when the checkbox is indeterminate, use `swap-indeterminate` class
+
+
+### tab
+Tabs can be used to show a list of links in a tabbed format
+
+[tab docs](https://daisyui.com/components/tab/)
+
+#### Class Names:
+- Component: `tabs`
+- Part: `tab`, `tab-content`
+- Style: `tabs-box`, `tabs-border`, `tabs-lift`
+- Modifier: `tab-active`, `tab-disabled`
+- Placement: `tabs-top`, `tabs-bottom`
+
+#### Syntax
+Using buttons:
+```html
+<div role="tablist" class="tabs {MODIFIER}">
+ <button role="tab" class="tab">Tab</button>
+</div>
+```
+
+Using radio inputs:
+```html
+<div role="tablist" class="tabs tabs-box">
+ <input type="radio" name="my_tabs" class="tab" aria-label="Tab" />
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the style/size class names
+- Radio inputs are needed for tab content to work with tab click
+- If tabs gets a background then every tab inside it becomes rounded from both top corners
+
+
+### table
+Table can be used to show a list of data in a table format
+
+[table docs](https://daisyui.com/components/table/)
+
+#### Class Names:
+- Component: `table`
+- Modifier: `table-zebra`, `table-pin-rows`, `table-pin-cols`
+- Size: `table-xs`, `table-sm`, `table-md`, `table-lg`, `table-xl`
+
+#### Syntax
+```html
+<div class="overflow-x-auto">
+ <table class="table {MODIFIER}">
+ <thead>
+ <tr>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th></th>
+ </tr>
+ </tbody>
+ </table>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each modifier/size class names
+- The `overflow-x-auto` class is added to the wrapper div to make the table horizontally scrollable on smaller screens
+
+
+### text-rotate
+Text Rotate can show up to 6 lines of text, one at a time, with a an infinite loop animation. Duration is 10 seconds by default. The animation will pause on hover.
+
+[textarea docs](https://daisyui.com/components/text-rotate/)
+
+#### Class Names:
+- Component: `text-rotate`
+
+#### Syntax
+```html
+<span class="text-rotate">
+ <span>
+ <span>Word 1</span>
+ <span>Word 2</span>
+ <span>Word 3</span>
+ <span>Word 4</span>
+ <span>Word 5</span>
+ <span>Word 6</span>
+ </span>
+</span>
+```
+Example:
+Big font size, horizontally centered
+```html
+<span class="text-rotate max-md:text-3xl text-7xl font-title">
+ <span class="justify-items-center">
+ <span>DESIGN</span>
+ <span>DEVELOP</span>
+ <span>DEPLOY</span>
+ <span>SCALE</span>
+ <span>MAINTAIN</span>
+ <span>REPEAT</span>
+ </span>
+</span>
+```
+Rotating words in a sentence, different colors for each word
+```html
+<span>
+ Providing AI Agents for
+ <span class="text-rotate">
+ <span>
+ <span class="bg-teal-400 text-teal-800 px-2">Designers</span>
+ <span class="bg-red-400 text-red-800 px-2">Developers</span>
+ <span class="bg-blue-400 text-blue-800 px-2">Managers</span>
+ </span>
+ </span>
+</span>
+```
+Custom line height in case you have a tall font or need more vertical spacing between lines
+```html
+<span class="text-rotate max-md:text-3xl text-7xl font-title leading-[2]">
+ <span class="justify-items-center">
+ <span>📐 DESIGN</span>
+ <span>💻 DEVELOP</span>
+ <span>🌎 DEPLOY</span>
+ <span>🌱 SCALE</span>
+ <span>🔧 MAINTAIN</span>
+ <span>🔁 REPEAT</span>
+ </span>
+</span>
+```
+
+#### Rules
+- `text-rotate` must have one span or div inside it that contains 2 to 6 spans/divs for each line of text
+- Total duration of the loop is 10000 milliseconds by default
+- You can set custom duration using `duration-{value}` utility class, where value is in milliseconds (e.g. `duration-12000` for 12 seconds)
+
+
+### textarea
+Textarea allows users to enter text in multiple lines
+
+[textarea docs](https://daisyui.com/components/textarea/)
+
+#### Class Names:
+- Component: `textarea`
+- Style: `textarea-ghost`
+- Color: `textarea-neutral`, `textarea-primary`, `textarea-secondary`, `textarea-accent`, `textarea-info`, `textarea-success`, `textarea-warning`, `textarea-error`
+- Size: `textarea-xs`, `textarea-sm`, `textarea-md`, `textarea-lg`, `textarea-xl`
+
+#### Syntax
+```html
+<textarea class="textarea {MODIFIER}" placeholder="Bio"></textarea>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each style/color/size class names
+
+
+### theme-controller
+If a checked checkbox input or a checked radio input with theme-controller class exists in the page, The page will have the same theme as that input's value
+
+[theme-controller docs](https://daisyui.com/components/theme-controller/)
+
+#### Class names
+- component: `theme-controller`
+
+#### Syntax
+```html
+<input type="checkbox" value="{theme-name}" class="theme-controller" />
+```
+
+#### Rules
+- The value attribute of the input element should be a valid daisyUI theme name
+
+
+### timeline
+Timeline component shows a list of events in chronological order
+
+[timeline docs](https://daisyui.com/components/timeline/)
+
+#### Class Names:
+- Component: `timeline`
+- Part: `timeline-start`, `timeline-middle`, `timeline-end`
+- Modifier: `timeline-snap-icon`, `timeline-box`, `timeline-compact`
+- Direction: `timeline-vertical`, `timeline-horizontal`
+
+#### Syntax
+```html
+<ul class="timeline {MODIFIER}">
+ <li>
+ <div class="timeline-start">{start}</div>
+ <div class="timeline-middle">{icon}</div>
+ <div class="timeline-end">{end}</div>
+ </li>
+</ul>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the modifier/direction class names
+- To make a vertical timeline, add the `timeline-vertical` class to the `ul` element or just do nothing (because its the default style.)
+- Add `timeline-snap-icon` to snap the icon to the start instead of middle
+- Add the `timeline-compact` class to force all items on one side
+
+
+### toast
+Toast is a wrapper to stack elements, positioned on the corner of page
+
+[toast docs](https://daisyui.com/components/toast/)
+
+#### Class Names:
+- Component: `toast`
+- Placement: `toast-start`, `toast-center`, `toast-end`, `toast-top`, `toast-middle`, `toast-bottom`
+
+#### Syntax
+```html
+<div class="toast {MODIFIER}">{CONTENT}</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of the placement class names
+
+
+### toggle
+Toggle is a checkbox that is styled to look like a switch button
+
+[toggle docs](https://daisyui.com/components/toggle/)
+
+#### Class Names:
+- Component: `toggle`
+- Color: `toggle-primary`, `toggle-secondary`, `toggle-accent`, `toggle-neutral`, `toggle-success`, `toggle-warning`, `toggle-info`, `toggle-error`
+- Size: `toggle-xs`, `toggle-sm`, `toggle-md`, `toggle-lg`, `toggle-xl`
+
+#### Syntax
+```html
+<input type="checkbox" class="toggle {MODIFIER}" />
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each color/size class names
+
+
+### tooltip
+Tooltip can be used to show a message when hovering over an element
+
+[tooltip docs](https://daisyui.com/components/tooltip/)
+
+#### Class names
+- component: `tooltip`
+- part: `tooltip-content`
+- modifier: `tooltip-open`
+- placement: `tooltip-top`, `tooltip-bottom`, `tooltip-left`, `tooltip-right`
+- color: `tooltip-primary`, `tooltip-secondary`, `tooltip-accent`, `tooltip-info`, `tooltip-success`, `tooltip-warning`, `tooltip-error`
+
+#### Syntax
+```html
+<div class="tooltip {MODIFIER}" data-tip="Tooltip text">
+ <button class="btn">Hover me</button>
+</div>
+```
+
+#### Rules
+- {MODIFIER} is optional and can have one of each modifier/placement/color class names
+
+
+### validator
+Validator class changes the color of form elements to error or success based on input's validation rules
+
+[validator docs](https://daisyui.com/components/validator/)
+
+#### Class names
+- component: `validator`
+- part: `validator-hint`
+
+#### Syntax
+```html
+<input type="{type}" class="input validator" required />
+<p class="validator-hint">Error message</p>
+```
+
+#### Rules
+- Use with `input`, `select`, `textarea`
diff --git a/.dispatch/rules/frontend-inject-transport.md b/.dispatch/rules/frontend-inject-transport.md
deleted file mode 100644
index fbe83d7..0000000
--- a/.dispatch/rules/frontend-inject-transport.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Rule: inject the transport; parsers are pure
-
-The WS/NDJSON framing + parsing is a PURE function (bytes/messages → typed events);
-the socket/fetch is INJECTED. Test the parser with crafted chunk inputs (and
-trace-replay-style fixtures), never a live connection. The op-protocol core is a
-pure state machine: `reduce(intent, incoming) → { viewModel, outgoingCommands }`;
-the carrier (WebSocket) is the injected shell.
diff --git a/.dispatch/rules/frontend-interpreter-generic.md b/.dispatch/rules/frontend-interpreter-generic.md
deleted file mode 100644
index 557f2d4..0000000
--- a/.dispatch/rules/frontend-interpreter-generic.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Rule: the surface interpreter is generic
-
-The surface interpreter switches on field KINDS (toggle/progress/selector/stat/
-button/custom), NEVER on a surface id. An `if (surface.id === "...")` imports a
-feature's identity into the platform and breaks isolation (guardrail 1). An
-unknown field `kind` or a `custom` `rendererId` with no registered renderer →
-GRACEFUL SKIP, never a crash. Render from the spec; the backend owns what a
-surface contains.
diff --git a/.dispatch/rules/frontend-no-ambient-state.md b/.dispatch/rules/frontend-no-ambient-state.md
deleted file mode 100644
index 663bf1a..0000000
--- a/.dispatch/rules/frontend-no-ambient-state.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Rule: no ambient state (frontend)
-
-State is owned per-unit and passed explicitly. NO module-global mutable store
-reached from everywhere — that is the old FE's "tools leak across tabs" /
-"model resets on tab switch" bug class. Svelte runes (`$state`) are a THIN
-reactive wrapper over a pure reducer, never the home of logic. Subscriptions are
-owned and disposed on unmount (no orphaned or duplicate subscriptions).
diff --git a/.dispatch/rules/frontend-pure-core.md b/.dispatch/rules/frontend-pure-core.md
deleted file mode 100644
index fa7bc2e..0000000
--- a/.dispatch/rules/frontend-pure-core.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Rule: pure core / injected shell (frontend)
-
-Decision logic — reducers, view-models, formatters, parsers — is pure
-(input → output): NO DOM, NO `fetch`/WebSocket, NO Svelte import. Put it in a
-`.ts` module that tests with zero mounting and zero mocks. Effects (socket, fetch,
-IndexedDB, clock) are INJECTED at the edges (props or an adapter). This is for
-testability, not purity dogma — stop where it would only add ceremony.
diff --git a/.dispatch/rules/frontend-styling.md b/.dispatch/rules/frontend-styling.md
deleted file mode 100644
index e123619..0000000
--- a/.dispatch/rules/frontend-styling.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# Rule: styling is DaisyUI v5 + the dracula theme (UI units only)
-
-The global stylesheet already enables **DaisyUI v5** with the **dracula** theme
-(`data-theme` on `<html>`). Do NOT add or change theme config, re-import Tailwind/DaisyUI,
-or hand-roll a design system — just apply DaisyUI / Tailwind utility classes in your
-`.svelte` files (e.g. `select`, `textarea`, `btn btn-primary`, `tabs`, `chat chat-start` /
-`chat-end` + `chat-bubble`).
-
-Keep components THIN: a `.svelte` file wires props/events to pure logic and applies classes
-— it holds NO business logic (that stays in `logic/` / the reducer). Render plain semantic
-HTML decorated with classes. biome lints `.ts`/`.js` only; `.svelte` correctness is
-`svelte-check`'s job.
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md
index 3a7a59c..0582d94 100644
--- a/.dispatch/transport-contract.reference.md
+++ b/.dispatch/transport-contract.reference.md
@@ -1,44 +1,143 @@
# `@dispatch/transport-contract` — in-repo reference (read THIS, not node_modules)
> MIRRORS the backend's `@dispatch/transport-contract` package source so headless FE agents can read
-> the HTTP + WebSocket wire shapes WITHOUT following the `file:` dep symlink out of this repo (which
-> hangs on a permission prompt). Your CODE still imports `@dispatch/transport-contract` normally —
-> this file is for READING only.
+> the transport types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a
+> permission prompt). Your CODE still imports `@dispatch/transport-contract` normally — this file is for
+> READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever it changes.
-> Depends on `@dispatch/wire` (see `wire.reference.md`) + `@dispatch/ui-contract`
-> (see `ui-contract.reference.md`).
-
-## Endpoints (backend, confirmed live — CORS wildcard `*`, HTTP port 24203, WS port 24205)
-
-- `POST /chat` — body `ChatRequest` (JSON); response NDJSON stream, one `AgentEvent` per line;
- resolved id also in `X-Conversation-Id` header.
-- `GET /models` — `ModelsResponse`.
-- `GET /conversations/:id?sinceSeq=<n>` — `ConversationHistoryResponse`: RAW, append-order,
- seq-ordered slice with `seq > n` (NOT reconciled — dangling tool-calls returned as-is).
- `latestSeq` = last chunk's `seq`, or the requested `sinceSeq` when caught up (empty `chunks`).
-- WebSocket on :24205 — ONE path-agnostic socket multiplexes surface ops
- (`@dispatch/ui-contract`) + chat ops (below). Open once, send `WsClientMessage`, receive
- `WsServerMessage`. Live `AgentEvent` deltas carry `conversationId`+`turnId` but **no `seq`**
- (seq lives only on `StoredChunk`, obtained via the `sinceSeq` sync after `turn-sealed`).
-- DEFERRED (not built; do not depend on): `GET /conversations` (list), `POST /conversations/:id/cancel`.
-
-```ts
+> **Orchestrator:** SNAPSHOT of `[email protected]` (MCP status + computers + provider concurrency). Regenerate whenever
+> it changes.
+>
+> **2026-06-27 update (concurrency-fixes — ADDITIVE, NO version bump):** the provider concurrency surface gains
+> (a) a configurable + persisted per-provider release COOLDOWN, and (b) adaptive headroom. `ConcurrencyStatusEntry`
+> gains FOUR new fields: `cooldownMs: number` (REQUIRED — per-slot release cooldown in ms, default 350; a recycled slot is
+> held this long before the next waiter is admitted), `autoReduced: boolean` (REQUIRED — true when the limit was auto-reduced
+> by 1 after a 429, one-way + persisted; the FE renders a visible banner), `autoReducedFrom?: number` (present only when
+> `autoReduced===true` — the original limit before reduction), and `notice?: string` (present only when `autoReduced===true` —
+> a human-readable banner message). The banner is DISMISSIBLE / persists while `autoReduced===true`; it clears when the user
+> restores the limit via `PUT /concurrency/limits/:providerId` (a manual PUT clears `autoReduced` server-side). NEW cooldown
+> endpoints: `GET /concurrency/cooldown/:providerId` → `ConcurrencyCooldownResponse` (`{ providerId, cooldownMs }`) — 404 when
+> the provider has no concurrency config at all (no limit, no cooldown), 503 when the extension isn't loaded;
+> `PUT /concurrency/cooldown/:providerId` ← `SetConcurrencyCooldownRequest` (`{ cooldownMs }` — must be a non-negative integer,
+> 0 = no cooldown / instant re-admission) → `ConcurrencyCooldownResponse` — 400 on an invalid body, 503 when not loaded.
+> Persists + applies immediately to subsequently recycled slots. Also (backend-only, no FE surface): a usage gate polls upstream
+> `concurrent_sessions` before admitting a queued agent. See `backend-handoff.md` §2j-update-3.
+>
+> **2026-06-26 delta (provider concurrency — `[email protected]` bump):** adds the
+> per-provider concurrency-limits API types: `ConcurrencyLimitsResponse` (`GET /concurrency/limits`),
+> `SetConcurrencyLimitRequest` + `ConcurrencyLimitResponse` (`GET`/`PUT /concurrency/limits/:providerId`),
+> and `ConcurrencyStatusEntry` + `ConcurrencyStatusResponse` (`GET /concurrency/status`). The
+> `concurrency` extension tracks/limits in-flight token-generating requests per provider with
+> oldest-agent-first queueing; when it isn't loaded the list + status endpoints return empty arrays and
+> the single/PUT/DELETE return `503`. See `backend-handoff.md` §2j.
+> **2026-06-26 delta (vision handoff — ADDITIVE, NO version bump):** adds the vision/image surface.
+> `ChatRequest` (+ `ChatSendMessage`/`QueueRequest`) gains an optional `images?: readonly ImageInput[]`
+> (each entry: `{ url, mimeType? }` — a base64 data URL or `http(s)://` URL; validated non-array/no-url/
+> empty-url → 400, empty array treated as absent). `ModelMetadata` gains `vision?: boolean` (true when the
+> model natively accepts images; absent → the server's vision handoff transcribes images to text before the
+> model sees them). `ImageChunk`/`ImageInput` are `@dispatch/wire` types (re-exported here).
+>
+> **2026-06-26 update (consult_vision + vision settings — ADDITIVE, NO version bump):** the `read_image`
+> tool is REPLACED by `consult_vision` (`{ question: string, imageIds?: number[], path?: string }`) — it
+> opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the
+> vision model's answer (rendered like any tool call/result). Non-vision models now get NUMBERED
+> PLACEHOLDERS (`[Image N attached — call consult_vision with imageIds=[N] and a specific question to
+> analyze it]`) instead of auto-transcriptions — these are regular `text` chunks (render as-is). Image
+> compaction transcribes the oldest images past `imageLimit` to `[Compacted image]: <description>` text
+> chunks (also regular `text` — render as-is; the persisted `image` chunk stays for rendering). NEW global
+> vision settings API: `GET /settings/vision` → `VisionSettingsResponse` (`{ imageLimit, compactionModel }`),
+> `PUT /settings/vision` ← `SetVisionSettingsRequest` (partial: `imageLimit?` non-negative int, 0 = disable
+> compaction; `compactionModel?` `<key>/<model>` or null = auto). See `backend-handoff.md` §2j.
+>
+> **2026-06-26 update (image storage — NO type change, behavior only):** persisted `ImageChunk.url`s are now
+> compact relative HTTP paths (`/images/<conversationId>/<uuid>.png`) served by the new
+> `GET /images/:conversationId/:imageId` endpoint (raw image bytes + correct Content-Type) — NOT base64 data
+> URLs (images are stored on disk under tmp, not in the SQLite store). `ChatRequest.images` (`ImageInput.url`)
+> is UNCHANGED — clients still send data URLs; the backend saves them to tmp and returns compact paths in
+> the persisted chunks. A client resolves a relative `url` against its API base (`resolveImageUrl`); the
+> optimistic echo's data URL and any absolute URL pass through. See `backend-handoff.md` §2j.
+>
+> **2026-06-25 delta (SSH handoff #2 — ADDITIVE to `[email protected]`, NO version bump):** adds the
+> computer HTTP API types: `ComputerListResponse` (`GET /computers`), `ComputerResponse` (`GET /computers/:alias`),
+> `ComputerStatusResponse` (`GET /computers/:alias/status`), `TestComputerResponse` (`POST /computers/:alias/test`),
+> `SetConversationComputerRequest` + `ConversationComputerResponse`
+> (`GET`/`PUT`/`DELETE /conversations/:id/computer`), `SetWorkspaceDefaultComputerRequest`
+> (`PUT /workspaces/:id/default-computer`). Also `computerId?: string` on `ChatRequest`/`ChatSendMessage`/
+> `QueueRequest` (per-turn override; resolved server-side from the persisted per-conversation value in the MVP, so
+> `chat.send` need not send it). `Computer`/`ComputerEntry` themselves are `@dispatch/wire` types. See
+> `backend-handoff.md` §2e. (The `ssh` extension that provides the ComputerService is the last backend wave —
+> until it lands, `GET /computers` returns `[]` and statuses return `disconnected`.)
+>
+> **2026-06-24 delta (MCP status handoff — package bumped `0.18.0` → `0.22.0`, ADDITIVE):** adds
+> `McpServerState`, `McpServerInfo`, and `McpStatusResponse`; endpoint
+> `GET /conversations/:id/mcp`. Mirrors the existing `GET /conversations/:id/lsp` shape (returns
+> `{cwd, servers}`, empty `servers` when no cwd is set). Each `McpServerInfo` reports an `id`,
+> `state` (`connecting` | `connected` | `error` | `disconnected`), optional `error`, `toolCount`,
+> and optional `configSource`. Also adds the previously-missing `configSource` field to
+> `LspServerInfo`. See `frontend-mcp-status-handoff.md`.
+>
+> **2026-06-24 delta (system prompt handoff — package bumped `0.17.0` → `0.18.0`, ADDITIVE):** adds
+> `SystemPromptTemplateResponse`, `SetSystemPromptTemplateRequest`, `SystemPromptVariable`, and
+> `SystemPromptVariablesResponse`; endpoints `GET /system-prompt`, `PUT /system-prompt`, and
+> `GET /system-prompt/variables`. The system prompt template is global (resolved once per conversation
+> at construction time, persisted for cache safety). Variables include `system:*`, `prompt:*`, `git:*`,
+> and dynamic `file:<path>`; conditional blocks use `[if]`, `[else]`, `[endif]`. See
+> `frontend-system-prompt-handoff.md`.
+
/**
* Transport contract — the typed description of Dispatch's client–server API
- * (HTTP + WebSocket). Types-only (zero runtime). Each side owns its own
- * (de)serialization — the contract is the SHAPES, not the codec.
+ * (HTTP + WebSocket).
+ *
+ * This package is types-only (zero runtime). It is the single shared surface
+ * every client imports to know how to talk to the backend — the CLI, the web
+ * frontend (in its own repo), any third-party client — and the transport-http /
+ * transport-ws servers import to know what they must accept and emit.
*
- * The WebSocket carries BOTH chat ops (here) and surface ops (in
+ * Each side owns its OWN (de)serialization: there is deliberately no shared
+ * parse/serialize helper here (isolation-over-DRY). The contract is the SHAPES,
+ * not the codec. The streaming response payload is the kernel's `AgentEvent`
+ * union, re-exported here so a client has one import for the whole wire.
+ *
+ * The WebSocket carries BOTH chat ops (defined here) and surface ops (defined in
* `@dispatch/ui-contract`) over one connection; the unified `WsClientMessage` /
* `WsServerMessage` unions below compose them. Chat ops are new, non-colliding
- * `type` variants (`chat.*`) — the shipped surface protocol is unchanged.
+ * `type` variants — there is no channel wrapper, so the shipped surface protocol
+ * is unchanged.
*/
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import type {
+ AgentEvent,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageInput,
+ QueuedMessage,
+ ReasoningEffort,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
+} from "@dispatch/wire";
-export type { AgentEvent, StoredChunk } from "@dispatch/wire";
+export type {
+ AgentEvent,
+ CompactionResult,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageChunk,
+ ImageInput,
+ QueuedMessage,
+ ReasoningEffort,
+ StepMetrics,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
+} from "@dispatch/wire";
/**
* Request body for `POST /chat` (sent as JSON).
@@ -48,49 +147,557 @@ export type { AgentEvent, StoredChunk } from "@dispatch/wire";
* response header (useful when `conversationId` was omitted).
*/
export interface ChatRequest {
- /** The conversation to continue. Omit to start fresh — server mints an id (X-Conversation-Id). */
+ /**
+ * The conversation to continue. Omit to start a fresh conversation — the
+ * server mints an id and returns it via the `X-Conversation-Id` header.
+ */
readonly conversationId?: string;
+
/** The user's message text for this turn. */
readonly message: string;
- /** Model name in `<credentialName>/<model>` form (one of `GET /models`). Omit = server default. */
+
+ /**
+ * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's
+ * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://`
+ * URL. The server converts these to `image` chunks on the persisted user
+ * message. For a VISION-capable model (e.g. kimi), the images are passed
+ * through to the provider natively. For a NON-vision model (e.g. glm-5.2),
+ * the server's vision handoff transcribes each image to a text description
+ * (via a vision-capable model) and feeds that text instead — so a text-only
+ * model can still reason about the image's contents. Optional — omit for a
+ * text-only turn (backward compatible). Validation: non-array `images` →
+ * 400; an image without `url` → 400; empty `url` → 400. An empty array is
+ * accepted and treated as absent.
+ */
+ readonly images?: readonly ImageInput[];
+
+ /**
+ * The model to use, as a model name in `<credentialName>/<model>` form — one
+ * of the exact strings returned by `GET /models`. Omit to use the server's
+ * default credential + model.
+ */
readonly model?: string;
- /** Working directory for this turn's tool execution. Defaults server-side. Not part of the prompt. */
+
+ /**
+ * Working directory for this turn's tool execution. Defaults server-side when
+ * omitted. Forwarded to tools for path resolution; never part of the model
+ * prompt (so it does not affect prompt caching).
+ */
readonly cwd?: string;
+
+ /**
+ * Reasoning-effort override for THIS turn only (does not persist). When
+ * omitted, the server resolves the conversation's persisted value, falling
+ * back to `"high"`. Must be one of the `ReasoningEffort` levels; an
+ * unrecognized value → HTTP 400 `{ error }`.
+ */
+ readonly reasoningEffort?: ReasoningEffort;
+
+ /**
+ * The workspace to assign this conversation to. Omit for `"default"`.
+ * If the workspace doesn't exist yet, it is auto-created (title = id,
+ * defaultCwd = null).
+ */
+ readonly workspaceId?: string;
+
+ /**
+ * The computer to run this turn's tools on — an SSH config `Host` alias
+ * (one of the `alias` values returned by `GET /computers`). Omit to inherit
+ * the resolved chain: per-conversation `computerId` → the workspace's
+ * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is
+ * a per-turn tool-execution target forwarded to tools and never part of the
+ * model prompt (so it does not affect prompt caching). Mirrors `cwd`.
+ */
+ readonly computerId?: string;
}
/**
- * Response body for `GET /models` — the model catalog. Each entry is a model
- * name in `<credentialName>/<model>` form (exactly `ChatRequest.model`).
+ * Response body for `GET /models` — the model catalog.
+ *
+ * Each entry in `models` is a model name in `<credentialName>/<model>` form:
+ * exactly the string a client passes back as `ChatRequest.model`.
+ * `modelInfo` is an optional map from the same `<credentialName>/<model>` key
+ * to model metadata (e.g. `contextWindow`). Additive — clients that only
+ * read `models` are unaffected.
*/
export interface ModelsResponse {
readonly models: readonly string[];
+ readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
+}
+
+/** Per-model metadata returned alongside the model catalog. */
+export interface ModelMetadata {
+ readonly contextWindow?: number;
+ /**
+ * Whether this model can natively accept image input (vision/multimodal).
+ * When `true`, image chunks in a user message are passed through to the
+ * provider. When `false`/absent, the server's vision handoff transcribes
+ * images to text before the model sees them. A client may use this to show
+ * a vision badge in the model picker. Optional — absent when unknown.
+ */
+ readonly vision?: boolean;
}
/**
- * Response body for `GET /conversations/:id?sinceSeq=<n>` — the incremental
- * read-side history endpoint a long-lived client uses to (re)hydrate cheaply.
+ * Response body for
+ * `GET /conversations/:id?sinceSeq=<n>&beforeSeq=<s>&limit=<k>` — the
+ * incremental read-side history endpoint a long-lived client uses to
+ * (re)hydrate a conversation cheaply. All three query params are OPTIONAL and
+ * combine as one SELECTION + one WINDOW:
+ *
+ * - **Selection** — `sinceSeq` (exclusive lower bound, `seq > n`; omitted/0 =
+ * from the start) and `beforeSeq` (exclusive upper bound, `seq < s`; omitted
+ * = to the end). Together: `n < seq < s`.
+ * - **Window** — `limit=<k>` returns only the NEWEST `k` chunks of the
+ * selection (the response stays ASCENDING by seq). A selection with ≤ `k`
+ * chunks is returned whole. `limit` omitted = the full selection — exactly
+ * the pre-windowing behavior, so existing clients are unchanged.
+ * - `limit` and `beforeSeq` must be POSITIVE integers (`sinceSeq` may be 0);
+ * malformed, zero, or negative values → HTTP 400 `{ error }`.
+ *
+ * Intended client flows: fresh load = `?sinceSeq=0&limit=<k>` (newest window);
+ * tail sync = `?sinceSeq=<cursor>` (no limit); page older history in =
+ * `?beforeSeq=<oldestLoadedSeq>&limit=<k>`.
*
- * `chunks` is the RAW, append-order, seq-ordered slice with `seq > sinceSeq`
- * (or the whole log when `sinceSeq` is omitted/0). NOT reconciled: a dangling
- * tool-call is returned as-is. `latestSeq` is the `seq` of the LAST chunk, or —
- * when the slice is empty (caught up) — the requested `sinceSeq` (0 for a full
- * read of an empty conversation). After applying, the client's new cursor is
- * always `latestSeq`; empty `chunks` means "nothing new past your cursor".
+ * Seq numbering is **1-based and gap-free** (a CONTRACTUAL GUARANTEE — see
+ * `StoredChunk` in `@dispatch/wire`): a client can derive "older chunks exist"
+ * purely from `oldestLoaded.seq > 1`; there is deliberately no
+ * `earliestSeq`/`hasOlder` response field.
+ *
+ * `chunks` is the RAW, append-order, seq-ordered slice of the conversation log
+ * selected + windowed as above. It is NOT reconciled: a dangling tool-call is
+ * returned as-is (rendered as an interrupted call). Reconciliation is a
+ * turn-path concern — the server repairs history only when it feeds a provider,
+ * never on this read path — which is what preserves the per-chunk `seq` cursor
+ * invariant (a synthesized repair chunk would have no seq).
+ *
+ * `latestSeq` is the `seq` of the LAST chunk in this response, or — when the
+ * slice is empty (the client is already caught up) — the requested `sinceSeq`
+ * (0 for a full read of an empty conversation). So after applying the response a
+ * client's new cursor is always `latestSeq`, and an empty `chunks` means
+ * "nothing new past your cursor". CAVEAT (windowed reads): `latestSeq` is a
+ * TAIL-sync cursor — on a `beforeSeq` backfill page (or any `limit`ed read that
+ * did not reach the log's true tail) it describes the returned window, NOT the
+ * conversation's high-water mark, so a client must not regress its sync cursor
+ * from a backfill response. (A true server-side high-water mark independent of
+ * the filter is deferred until a consumer needs it — it would require widening
+ * the store contract.)
*/
export interface ConversationHistoryResponse {
readonly chunks: readonly StoredChunk[];
readonly latestSeq: number;
}
+/**
+ * Response body for `GET /conversations/:id/metrics` — the persisted per-turn
+ * (and per-step) token + timing metrics for a conversation, for a client
+ * reopening a past conversation to render historical usage/latency.
+ *
+ * This is a SEPARATE axis from the two other read concerns and is deliberately
+ * its own endpoint: the live `usage`/`step-complete`/`done` events are transient
+ * (not persisted), and `ConversationHistoryResponse` carries seq-cursor chunk
+ * CONTENT. Metrics are keyed per TURN (not per chunk) and so are not seq-filtered
+ * — hence a sibling route rather than a field on the history response.
+ *
+ * `turns` is every SEALED turn's `TurnMetrics` in turn order. A turn appears only
+ * after its metrics were persisted (post-seal); an in-flight or unsealed turn is
+ * absent until then.
+ */
+export interface ConversationMetricsResponse {
+ readonly turns: readonly TurnMetrics[];
+}
+
+export interface ConversationStatusResponse {
+ readonly conversationId: string;
+ /** True if the orchestrator has an in-memory active turn for this conversation. */
+ readonly isActive: boolean;
+ /** The persisted lifecycle status from the conversation store. */
+ readonly status: ConversationStatus;
+}
+
+/** The aggregation window for `GET /metrics/throughput`. */
+export type ThroughputPeriod = "day" | "week" | "month";
+
+/**
+ * One model's throughput over a period. `tokensPerSecond` is the TOKEN-WEIGHTED
+ * average — `Σ(output tokens) / Σ(generation seconds)` across the period's
+ * turns — so larger turns count proportionally more than smaller ones.
+ * Generation time is the model's pure decode time (it excludes tool-execution
+ * waits).
+ */
+export interface ThroughputModelStat {
+ /** The model name in `<credentialName>/<model>` form (as selected). */
+ readonly model: string;
+ /** Token-weighted average tokens/second over the period. */
+ readonly tokensPerSecond: number;
+ /** Total output tokens generated across the period's turns. */
+ readonly totalOutputTokens: number;
+ /** Total pure generation time across the period's turns, in milliseconds. */
+ readonly totalGenMs: number;
+ /** Number of turns that contributed. */
+ readonly turns: number;
+}
+
+/**
+ * Response body for
+ * `GET /metrics/throughput?period=day|week|month&date=<...>`.
+ *
+ * `date` is `YYYY-MM-DD` for day/week (week = the ISO Mon–Sun week containing
+ * that date) and `YYYY-MM` for month. Boundaries are computed in the server's
+ * local timezone; `start`/`end` are the resolved half-open `[start, end)` range
+ * in epoch-ms. `models` lists every model active in the window, sorted by
+ * `tokensPerSecond` descending.
+ */
+export interface ThroughputResponse {
+ readonly period: ThroughputPeriod;
+ readonly date: string;
+ /** Inclusive start of the window, epoch-ms. */
+ readonly start: number;
+ /** Exclusive end of the window, epoch-ms. */
+ readonly end: number;
+ readonly models: readonly ThroughputModelStat[];
+}
+
+// ─── Per-conversation working directory (cwd) ─────────────────────────────────
+
+/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */
+export interface CwdResponse {
+ readonly conversationId: string;
+ readonly cwd: string | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/cwd`.
+ *
+ * When `workspaceId` is provided, the conversation is assigned to that
+ * workspace BEFORE the cwd is persisted — so a subsequent
+ * `GET /conversations/:id/lsp` resolves a relative cwd against the
+ * workspace's `defaultCwd` (not the server default). Omit for unchanged
+ * workspace assignment (the conversation keeps its current workspace, or
+ * `"default"` if none).
+ */
+export interface SetCwdRequest {
+ readonly cwd: string;
+ readonly workspaceId?: string;
+}
+
+// ─── Per-conversation reasoning effort ────────────────────────────────────────
+
+/**
+ * Response of `GET /conversations/:id/reasoning-effort`. `reasoningEffort` is
+ * null when never set (the server then resolves turns at the default,
+ * `"high"`).
+ */
+export interface ReasoningEffortResponse {
+ readonly conversationId: string;
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/reasoning-effort` — persists the
+ * conversation's sticky reasoning-effort level (used for every later turn that
+ * does not carry a per-turn `ChatRequest.reasoningEffort` override). An
+ * unrecognized level → HTTP 400 `{ error }`.
+ */
+export interface SetReasoningEffortRequest {
+ readonly reasoningEffort: ReasoningEffort;
+}
+
+// ─── Per-conversation model persistence ───────────────────────────────────────
+
+/**
+ * Response of `GET /conversations/:id/model`. `model` is the persisted model
+ * name in `<credentialName>/<model>` form, or null when never set (the server
+ * then resolves turns using the default provider + model).
+ */
+export interface ModelResponse {
+ readonly conversationId: string;
+ readonly model: string | null;
+}
+
+/**
+ * Body of `PUT /conversations/:id/model` — persists the conversation's sticky
+ * model selection (used for every later turn that does not carry a per-turn
+ * `ChatRequest.model` override). Pass `null` to clear the persisted selection.
+ * An unrecognized model name is not validated here (the provider resolves it
+ * at turn time; an unknown model → turn error, not a 400).
+ */
+export interface SetModelRequest {
+ readonly model: string | null;
+}
+
+// ─── Conversation close (explicit tab close) ──────────────────────────────────
+
+/**
+ * Response of `POST /conversations/:id/close` (no request body).
+ *
+ * The EXPLICIT "the user closed this conversation's tab" affordance — distinct
+ * from a socket disconnect or `chat.unsubscribe`, which deliberately never touch
+ * the turn or the warming schedule. Closing:
+ * 1. aborts any in-flight turn (the kernel stops at the next event boundary,
+ * partial messages are persisted, and the turn SEALS normally with
+ * `finishReason: "aborted"` — watchers see `done` + `turn-sealed`), and
+ * 2. stops + disables cache-warming for the conversation (persisted OFF, so a
+ * reopened conversation stays opt-in).
+ * Idempotent: closing an idle or unknown conversation succeeds with
+ * `abortedTurn: false`.
+ */
+export interface CloseConversationResponse {
+ readonly conversationId: string;
+ /** True when an in-flight turn existed and was aborted by this close. */
+ readonly abortedTurn: boolean;
+}
+
+// ─── System prompt template ───────────────────────────────────────────────────
+
+/**
+ * Response of `GET /system-prompt` — the current global system prompt template.
+ *
+ * The template is a text string with variable placeholders (`[type:name]`) and
+ * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first
+ * turn or compaction), variables are resolved against the conversation's cwd
+ * and system state. The resolved system prompt is persisted per conversation
+ * and reused on all subsequent turns (cache-safe — no per-turn reconstruction).
+ */
+export interface SystemPromptTemplateResponse {
+ /** The template text (may be empty — then no system prompt is sent). */
+ readonly template: string;
+}
+
+/**
+ * Body of `PUT /system-prompt` — set the global system prompt template.
+ *
+ * Changing the template does NOT affect existing conversations until they are
+ * compacted (the persisted resolved system prompt is stable). New
+ * conversations use the new template on their first turn.
+ */
+export interface SetSystemPromptTemplateRequest {
+ readonly template: string;
+}
+
+/**
+ * One available variable for the system prompt template, as reported by
+ * `GET /system-prompt/variables` so the frontend can render the variable
+ * selector buttons.
+ */
+export interface SystemPromptVariable {
+ /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */
+ readonly type: string;
+ /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */
+ readonly name: string;
+ /** Human-readable description of what the variable resolves to. */
+ readonly description: string;
+ /**
+ * When `true`, any name is valid for this type (e.g. `file:<path>` accepts
+ * any file path). The frontend should allow free-text input for the name.
+ */
+ readonly dynamic?: boolean;
+}
+
+/** Response of `GET /system-prompt/variables`. */
+export interface SystemPromptVariablesResponse {
+ readonly variables: readonly SystemPromptVariable[];
+}
+
+// ─── Vision settings (global) ───────────────────────────────────────────────
+
+/**
+ * Response of `GET /settings/vision` — the global vision configuration shared
+ * across all conversations and vision models.
+ */
+export interface VisionSettingsResponse {
+ /** Max native images per turn (default 10); 0 disables image compaction. */
+ readonly imageLimit: number;
+ /** Which model transcribes old images (null = auto-select a vision model). */
+ readonly compactionModel: string | null;
+}
+
+/** Body of `PUT /settings/vision` — a partial update. */
+export interface SetVisionSettingsRequest {
+ /** Non-negative integer (0 = disable compaction). */
+ readonly imageLimit?: number;
+ /** A model name (`<key>/<model>`) or null (auto). */
+ readonly compactionModel?: string | null;
+}
+
+// ─── Message queue (steering) ─────────────────────────────────────────────────
+
+/**
+ * Request body for `POST /conversations/:id/queue` — enqueue a user message
+ * onto a conversation's message queue for mid-turn steering delivery.
+ *
+ * When a turn is ACTIVE for the conversation, the message is appended to the
+ * queue (the message-queue extension's per-conversation SURFACE updates) and
+ * delivered at the next tool-result boundary as a steering message the model
+ * sees alongside the tool results (a `steering` `AgentEvent` is emitted). When
+ * NO turn is active, enqueuing instead STARTS a new turn with the message as its
+ * opening prompt (equivalent to `POST /chat`) — so a fire-and-forget enqueue
+ * works regardless of generation state. The resolved queue + whether a turn was
+ * started are returned in `QueueResponse`.
+ *
+ * `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise.
+ */
+export interface QueueRequest {
+ readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
+}
+
+/**
+ * Response body for `POST /conversations/:id/queue` — the conversation's queue
+ * snapshot AFTER the enqueue, so a client renders the queue from this alone.
+ * `conversationId` echoes the path. `startedTurn` is true when no turn was
+ * active and the enqueue started a new turn (the message is now the turn's
+ * opening prompt, not a queued steering message); the turn's events stream on
+ * the chat channel as usual.
+ */
+export interface QueueResponse {
+ readonly conversationId: string;
+ readonly startedTurn: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
+// ─── Per-conversation LSP status ──────────────────────────────────────────────
+
+/** The connection state of a single language server for a workspace. */
+export type LspServerState = "connected" | "starting" | "error" | "not-started";
+
+/** One language server's status as reported to the frontend. */
+export interface LspServerInfo {
+ /** Stable server id, e.g. "typescript", "luau-lsp". */
+ readonly id: string;
+ /** Human-readable display name. */
+ readonly name: string;
+ /** The resolved workspace root the server is (or would be) rooted at (absolute). */
+ readonly root: string;
+ /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */
+ readonly extensions: readonly string[];
+ /** Current connection state. */
+ readonly state: LspServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /**
+ * Which config source this server was resolved from: `".dispatch/lsp.json"`,
+ * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted
+ * when not yet resolved. Surfaces config-shadow debugging to the status caller
+ * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`).
+ */
+ readonly configSource?: string;
+}
+
+/** Response of `GET /conversations/:id/lsp`. */
+export interface LspStatusResponse {
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the LSP connects on, or `null` when no
+ * cwd has been set for the conversation (then `servers` is empty). When
+ * non-null, this is the effective cwd — a relative persisted cwd resolved
+ * against the conversation's workspace `defaultCwd`.
+ */
+ readonly cwd: string | null;
+ /** The language servers configured for `cwd` and their live state. */
+ readonly servers: readonly LspServerInfo[];
+}
+
+// ─── MCP status ──────────────────────────────────────────────────────
+
+export type McpServerState = "connecting" | "connected" | "error" | "disconnected";
+
+/** One MCP server's status as reported to the frontend. */
+export interface McpServerInfo {
+ /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */
+ readonly id: string;
+ /** Current connection state. */
+ readonly state: McpServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /** Number of tools discovered from this server. */
+ readonly toolCount: number;
+ /** Which config source this server was resolved from. */
+ readonly configSource?: string;
+}
+
+/** Response of `GET /conversations/:id/mcp`. */
+export interface McpStatusResponse {
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the MCP servers are configured for, or
+ * `null` when no cwd has been set for the conversation (then `servers` is
+ * empty). Mirrors the LSP status endpoint behavior.
+ */
+ readonly cwd: string | null;
+ /** The MCP servers configured for `cwd` and their live state. */
+ readonly servers: readonly McpServerInfo[];
+}
+
+/**
+ * Request body for `POST /chat/warm` — manually trigger a prompt-cache WARMING
+ * request for a conversation (e.g. a frontend "warm now" button, or fast tests
+ * that don't want to wait for the automatic warming timer).
+ *
+ * The warm replays the conversation's existing prefix to the provider to refresh
+ * its prompt cache; it is NEVER persisted and NEVER streamed (no `AgentEvent`s).
+ * Pass the same `model`/`cwd` the conversation chats with so the warm request's
+ * prefix is byte-identical to a real turn (which is what makes the cache hit).
+ */
+export interface WarmRequest {
+ /** The conversation whose prompt cache to warm. */
+ readonly conversationId: string;
+
+ /**
+ * The model name in `<credentialName>/<model>` form the conversation uses, so
+ * the warm resolves the same provider + prefix. Omit to use the server default.
+ */
+ readonly model?: string;
+
+ /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */
+ readonly cwd?: string;
+}
+
+/**
+ * Response body for `POST /chat/warm` (HTTP 200). The warm request's usage —
+ * never folded into the conversation's real usage. A client surfaces `cachePct`
+ * as the "last warming" cache-hit indicator.
+ *
+ * When warming cannot run because the conversation is currently generating, the
+ * server responds `409` with `{ error }` instead of this body.
+ */
+export interface WarmResponse {
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cacheReadTokens: number;
+ readonly cacheWriteTokens: number;
+ /**
+ * **Cache rate** — what fraction of THIS request's prompt was served from cache:
+ * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`).
+ * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].)
+ */
+ readonly cachePct: number;
+ /**
+ * **Expected cache (retention)** — of the cacheable prefix this warm touched, how
+ * much was still warm and read back vs. had to be (re)written:
+ * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the
+ * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still
+ * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite
+ * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's
+ * tiny fresh probe makes ~equal, but which on a real turn reflects new content).
+ */
+ readonly expectedCacheRate: number;
+}
+
// ─── WebSocket chat ops ───────────────────────────────────────────────────────
// The persistent WS connection multiplexes chat ops (below) with surface ops
-// (`@dispatch/ui-contract`). Chat `type`s are namespaced (`chat.*`) so they
-// never collide with surface ones.
+// (`@dispatch/ui-contract`). The unified unions at the bottom compose both. Chat
+// `type`s are namespaced (`chat.*`) so they never collide with surface ones.
/**
- * Client → server: start or continue a turn over the WS connection. Same fields
- * as the HTTP `ChatRequest`; omit `conversationId` to start fresh — the resolved
- * id arrives on the streamed `AgentEvent`s (each carries `conversationId`).
+ * Client → server: start or continue a turn over the WS connection. Carries the
+ * same fields as the HTTP `ChatRequest` (so one shape drives both transports);
+ * omit `conversationId` to start fresh — the resolved id arrives on the streamed
+ * `AgentEvent`s (each carries `conversationId`).
*/
export interface ChatSendMessage extends ChatRequest {
readonly type: "chat.send";
@@ -98,8 +705,9 @@ export interface ChatSendMessage extends ChatRequest {
/**
* Server → client: one `AgentEvent` from an in-flight turn (text-delta,
- * tool-call, usage, done, turn-sealed, …). Fold these into the transcript
- * exactly as the HTTP NDJSON stream — same events, different carrier.
+ * tool-call, usage, done, turn-sealed, …). The client folds these into its
+ * transcript exactly as it folds the HTTP NDJSON stream — same events, different
+ * carrier.
*/
export interface ChatDeltaMessage {
readonly type: "chat.delta";
@@ -117,9 +725,411 @@ export interface ChatErrorMessage {
readonly message: string;
}
-/** Every client → server WS message: surface ops + chat ops. Discriminate on `type`. */
-export type WsClientMessage = SurfaceClientMessage | ChatSendMessage;
+/**
+ * Client → server: start WATCHING a conversation's live turn events WITHOUT
+ * sending a message. This is what makes a turn viewable independently of who
+ * started it — a second device (multi-client handoff) or a client that reloaded
+ * mid-turn subscribes to receive the in-flight turn.
+ *
+ * On subscribe the server replays the CURRENT in-flight turn's events so far as
+ * `chat.delta` messages (so a late-joiner sees the whole running turn from its
+ * `turn-start`), then streams subsequent live events. If no turn is in-flight,
+ * nothing is replayed (the client relies on `GET /conversations/:id` history).
+ * A client infers "generating" from a replayed `turn-start` with no matching
+ * `done`/`turn-sealed` yet. Idempotent per `(connection, conversationId)`.
+ *
+ * NOTE: `chat.send` auto-subscribes the sending connection, so a client only needs
+ * `chat.subscribe` for conversations it is viewing but did not send to.
+ */
+export interface ChatSubscribeMessage {
+ readonly type: "chat.subscribe";
+ readonly conversationId: string;
+}
+
+/**
+ * Client → server: stop watching a conversation's turn events on this connection.
+ * Does NOT stop or affect the turn itself (the turn runs to completion regardless
+ * of subscribers). The server also drops all of a connection's subscriptions when
+ * the socket closes — again WITHOUT aborting any in-flight turn.
+ */
+export interface ChatUnsubscribeMessage {
+ readonly type: "chat.unsubscribe";
+ readonly conversationId: string;
+}
+
+/**
+ * Client → server: enqueue a message onto a conversation's message queue while
+ * a turn is generating (steering). The WebSocket counterpart of the HTTP
+ * `POST /conversations/:id/queue` (`QueueRequest`). Fire-and-forget: success is
+ * confirmed by the message-queue SURFACE updating (the FE renders the queue
+ * from the surface, not from a reply here); a failure (malformed/empty text,
+ * unknown conversation) arrives as a `chat.error`. When no turn is active, the
+ * enqueue starts a new turn (the turn's events stream as `chat.delta`s), so a
+ * client reuses this op for both "queue while generating" and "send" (the
+ * latter being equivalent to `chat.send`).
+ */
+export interface ChatQueueMessage {
+ readonly type: "chat.queue";
+ readonly conversationId: string;
+ readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
+}
+
+/**
+ * Every client → server WS message: surface ops (`@dispatch/ui-contract`) + chat
+ * ops. A server discriminates on `type`.
+ */
+export type WsClientMessage =
+ | SurfaceClientMessage
+ | ChatSendMessage
+ | ChatSubscribeMessage
+ | ChatUnsubscribeMessage
+ | ChatQueueMessage;
+
+/**
+ * Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
+ * ops. A client discriminates on `type`.
+ */
+export type WsServerMessage =
+ | SurfaceServerMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ConversationOpenMessage
+ | ConversationStatusChangedMessage
+ | ConversationCompactedMessage;
+
+// ─── Conversation list + metadata ────────────────────────────────────────────
+
+/**
+ * Broadcast to all connected WS clients when a conversation is "opened" (e.g.
+ * via the CLI `--open` flag). The frontend decides whether to open/focus a tab
+ * — the backend just signals. Additive to `WsServerMessage`.
+ */
+export interface ConversationOpenMessage {
+ readonly type: "conversation.open";
+ readonly conversationId: string;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
+}
+
+/**
+ * Broadcast to all connected WS clients when a conversation's lifecycle status
+ * changes (active/idle/closed). The frontend uses this to sync tab state across
+ * devices in real time.
+ */
+export interface ConversationStatusChangedMessage {
+ readonly type: "conversation.statusChanged";
+ readonly conversationId: string;
+ readonly status: ConversationStatus;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
+}
+
+/**
+ * Broadcast to all connected WS clients when a conversation's history has been
+ * compacted (summarized). The frontend should reload the conversation history
+ * via `GET /conversations/:id` to reflect the compacted state.
+ */
+export interface ConversationCompactedMessage {
+ readonly type: "conversation.compacted";
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+/**
+ * Response for `GET /conversations` — the list of all known conversations,
+ * sorted by `lastActivityAt` descending (most recent first). Each entry carries
+ * enough metadata for a conversation picker UI (id, title, timestamps).
+ * Optional `?q=` query param filters by id prefix (short-id resolution).
+ */
+export interface ConversationListResponse {
+ readonly conversations: readonly ConversationMeta[];
+}
+
+/**
+ * Response for `GET /conversations/:id/last` — blocks server-side until the
+ * in-flight turn settles (if one is active), then returns the last assistant
+ * text message. `content` is empty if the conversation has no assistant message.
+ * `turnId` is the turn that produced the message (absent if no turn ran).
+ */
+export interface LastMessageResponse {
+ readonly conversationId: string;
+ readonly content: string;
+ readonly turnId?: string;
+}
+
+/**
+ * Response for `POST /conversations/:id/open` — confirms the conversation.open
+ * signal was broadcast to connected WS clients.
+ */
+export interface OpenConversationResponse {
+ readonly conversationId: string;
+}
+
+/**
+ * Request body for `PUT /conversations/:id/title` — set a human-readable title.
+ */
+export interface SetTitleRequest {
+ readonly title: string;
+}
+
+/**
+ * Response for `GET/PUT /conversations/:id/title` — the current title.
+ */
+export interface TitleResponse {
+ readonly conversationId: string;
+ readonly title: string;
+}
+
+/**
+ * Response for `POST /conversations/:id/compact` — confirms the conversation
+ * history was compacted (old messages summarized, recent messages retained).
+ */
+export interface CompactResponse {
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+/**
+ * Response for `GET /conversations/:id/compact-percent` — the token count
+ * at which automatic compaction triggers (0 = manual only).
+ */
+export interface CompactPercentResponse {
+ readonly conversationId: string;
+ readonly threshold: number;
+}
+
+/**
+ * Request body for `PUT /conversations/:id/compact-percent`.
+ */
+export interface SetCompactPercentRequest {
+ readonly threshold: number;
+}
+
+// ─── Workspaces ───────────────────────────────────────────────────────────────
+
+/**
+ * Body of `PUT /workspaces/:id` — the idempotent create-on-miss call. All
+ * fields are optional and only applied when the workspace is first created;
+ * an existing workspace is returned as-is.
+ */
+export interface EnsureWorkspaceRequest {
+ /** Display title. Default: the workspace id. Only used on create. */
+ readonly title?: string;
+ /** Default cwd. Default: null (inherit server default). Only used on create. */
+ readonly defaultCwd?: string | null;
+}
+
+/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */
+export interface WorkspaceResponse extends Workspace {}
+
+/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */
+export interface WorkspaceListResponse {
+ readonly workspaces: readonly WorkspaceEntry[];
+}
+
+/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */
+export interface SetWorkspaceTitleRequest {
+ readonly title: string;
+}
+
+/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */
+export interface SetWorkspaceDefaultCwdRequest {
+ readonly defaultCwd: string | null;
+}
+
+/**
+ * Response of `DELETE /workspaces/:id`. All conversations in the workspace
+ * are closed (status → "closed") and reassigned to "default", then the
+ * workspace entity is deleted. `"default"` is non-deletable (HTTP 409).
+ */
+export interface DeleteWorkspaceResponse {
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
+}
+
+// ─── Computers (SSH handoff #2) ─────────────────────────────────────────────
+
+/**
+ * Response of `GET /computers` — every remote computer discovered from the
+ * system's `~/.ssh/config`, sorted by `alias`. Parallel to
+ * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a
+ * usage count). There is no Computer CRUD — to add one, the user adds a `Host`
+ * block to `~/.ssh/config` and Dispatch discovers it on the next read.
+ */
+export interface ComputerListResponse {
+ readonly computers: readonly ComputerEntry[];
+}
+
+/**
+ * Response of `GET /computers/:alias` — a single computer. Parallel to
+ * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users
+ * select; the remaining fields are resolved from the SSH config.
+ */
+export interface ComputerResponse extends Computer {}
+
+/**
+ * Response of `GET /computers/:alias/status` — the live connection state of a
+ * computer (whether Dispatch currently holds an open SSH session to it). Drives
+ * the frontend connection indicator. `error` is present only when
+ * `state === "error"`; `knownHost` mirrors the read-only `Computer` field.
+ */
+export interface ComputerStatusResponse {
+ readonly alias: string;
+ readonly state: "disconnected" | "connecting" | "connected" | "error";
+ readonly error?: string;
+ readonly knownHost: boolean;
+}
+
+/**
+ * Body of `PUT /conversations/:id/computer` — set or clear the conversation's
+ * persisted computer selection (the computer analog of `SetCwdRequest`). Pass
+ * `null` to clear → the conversation inherits the workspace's
+ * `defaultComputerId`, then `null`/local. An unknown alias is not validated here
+ * (the connection resolves at turn time; an unreachable host → turn error, not
+ * a 400). Mirrors the cwd/model PUT clear semantics.
+ */
+export interface SetConversationComputerRequest {
+ readonly computerId: string | null;
+}
+
+/**
+ * Response of `GET /conversations/:id/computer`. `computerId` is the persisted
+ * SSH `Host` alias, or `null` when never set (the conversation then inherits
+ * the workspace default → local). Parallel to `CwdResponse`.
+ */
+export interface ConversationComputerResponse {
+ readonly conversationId: string;
+ readonly computerId: string | null;
+}
-/** Every server → client WS message: surface ops + chat ops. Discriminate on `type`. */
-export type WsServerMessage = SurfaceServerMessage | ChatDeltaMessage | ChatErrorMessage;
+/**
+ * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's
+ * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`).
+ * `null` means local (no SSH). Conversations in the workspace with no
+ * `computerId` of their own inherit this.
+ */
+export interface SetWorkspaceDefaultComputerRequest {
+ readonly computerId: string | null;
+}
+
+/**
+ * Response of `POST /computers/:alias/test` — the result of a one-shot
+ * connectivity probe (Dispatch opens an SSH connection to the alias, runs a
+ * trivial command, then closes). `ok` is true on success; `error` carries the
+ * failure reason (e.g. auth refused, host unreachable) when `ok` is false.
+ */
+export interface TestComputerResponse {
+ readonly alias: string;
+ readonly ok: boolean;
+ readonly error?: string;
+}
+
+// ── Provider concurrency limits ([email protected]) ───────────────────
+
+/**
+ * Response of `GET /concurrency/limits` — all providers with configured
+ * concurrency limits. Each entry pairs a provider id (e.g. "umans",
+ * "openai-compat") with its maximum concurrent in-flight requests. Providers
+ * not listed here have no limit (unlimited).
+ */
+export interface ConcurrencyLimitsResponse {
+ readonly limits: readonly {
+ readonly providerId: string;
+ readonly limit: number;
+ }[];
+}
+/**
+ * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency
+ * limit for a provider. `limit` must be a positive integer. When a limit is
+ * set, requests beyond the limit queue (oldest-agent-first) rather than being
+ * sent immediately.
+ */
+export interface SetConcurrencyLimitRequest {
+ readonly limit: number;
+}
+/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */
+export interface ConcurrencyLimitResponse {
+ readonly providerId: string;
+ readonly limit: number;
+}
+/**
+ * One provider's live concurrency status.
+ *
+ * - `inFlight`: how many slots are currently held (tokens being generated).
+ * - `queued`: how many agents are waiting for a slot.
+ * - `paused`: whether the queue is paused due to a 429 backoff.
+ * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused.
+ * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held
+ * this long before the next waiter is admitted — covers the upstream
+ * provider's accounting lag. Configurable + persisted per provider.
+ * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429
+ * (adaptive headroom, one-way, persisted). The user restores the limit
+ * manually via `PUT /concurrency/limits/:providerId`, which clears the flag.
+ * When `true`, the frontend renders a visible notice/banner.
+ * - `autoReducedFrom`: the original limit before auto-reduction (present only
+ * when `autoReduced` is true).
+ * - `notice`: a human-readable notice string for the frontend to render as a
+ * banner when the limit was auto-reduced (present only when `autoReduced`).
+ */
+export interface ConcurrencyStatusEntry {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ readonly pausedUntil?: number;
+ readonly cooldownMs: number;
+ readonly autoReduced: boolean;
+ readonly autoReducedFrom?: number;
+ readonly notice?: string;
+}
+/**
+ * Response of `GET /concurrency/status` — live status for every provider with a
+ * configured limit. Providers without a limit are absent (they are unlimited).
+ */
+export interface ConcurrencyStatusResponse {
+ readonly providers: readonly ConcurrencyStatusEntry[];
+}
+
+// ─── Provider concurrency cooldown ────────────────────────────────────────────
+
+/**
+ * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release
+ * cooldown (ms) for a provider. A recycled slot is held this long before the
+ * next waiter is admitted, covering the upstream provider's accounting lag.
+ * When no cooldown was explicitly set, the server default (350ms) is returned.
+ */
+export interface ConcurrencyCooldownResponse {
+ readonly providerId: string;
+ readonly cooldownMs: number;
+}
+
+/**
+ * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown
+ * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no
+ * cooldown, instant re-admission). The value is persisted and applied to
+ * subsequently recycled slots.
+ */
+export interface SetConcurrencyCooldownRequest {
+ readonly cooldownMs: number;
+}
```
+
diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md
index 3962fc1..d751af8 100644
--- a/.dispatch/ui-contract.reference.md
+++ b/.dispatch/ui-contract.reference.md
@@ -5,22 +5,49 @@
> hangs on a permission prompt). Your CODE still imports `@dispatch/ui-contract` normally — this
> file is for READING only.
>
-> **Orchestrator:** this is a SNAPSHOT — regenerate it whenever `ui-contract` changes.
+> **Orchestrator:** this is a SNAPSHOT of `[email protected]` — regenerate it whenever
+> `ui-contract` changes.
+>
+> **2026-06-12 delta (CR-2/CR-4 handoff — package bumped `0.1.0` → `0.2.0`):** adds the optional
+> `scope?: "global" | "conversation"` to `SurfaceCatalogEntry` so a client can skip re-subscribing
+> GLOBAL surfaces on a conversation switch. ABSENT means assume conversation-scoped (the
+> conservative always-send-conversationId policy remains correct for both). Emitted today:
+> `loaded-extensions` → `"global"`, `cache-warming` → `"conversation"`. Also (CR-4d, no shape
+> change): the initial `surface` reply to a conversation-scoped subscribe ECHOES `conversationId`
+> as documented (was already on backend HEAD; verify with a freshly-booted backend).
+>
+> **2026-06 delta (cache-warming handoff):** adds the `NumberField` variant (`kind:"number"`) to
+> the `SurfaceField` union, and an OPTIONAL `conversationId?` to `SubscribeMessage` /
+> `UnsubscribeMessage` / `InvokeMessage` / `SurfaceMessage` / `SurfaceUpdate` so a surface can be
+> CONVERSATION-SCOPED (state differs per conversation, e.g. `cache-warming`) vs GLOBAL (one state for
+> all, e.g. `loaded-extensions`). All additive / backward-compatible: a global surface omits
+> `conversationId` and behaves exactly as before.
```ts
/**
* UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
*
* A SURFACE is a "data transportation surface": a typed description of what data an
- * extension exposes, its semantics, and the actions that can act on it — NOT UI.
- * Any client renders a surface in its own idiom (web/Svelte, CLI, future TUI/mobile).
- * Types-only, zero runtime, zero `@dispatch/*` deps.
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI. It
+ * carries STRUCTURE + SEMANTICS + ACTIONS, never styling and never a rendering-
+ * framework token. Any client (web/Svelte, CLI, future TUI/mobile) renders a surface
+ * in its own idiom, so swapping or adding a client is a zero-backend-change event.
+ *
+ * This package is types-only (zero runtime) and has ZERO `@dispatch/*` dependencies,
+ * so a separate client repo can depend on JUST this contract.
*/
-/** Where a surface mounts — a coarse, semantic placement hint, NOT layout/CSS. Open string. */
+/**
+ * Where a surface mounts — a coarse, semantic placement hint, NOT a layout/CSS
+ * instruction. A client maps a region to its own idiom; an unknown region falls back
+ * to the client's default placement. Deliberately left open (a `string`).
+ */
export type Region = string;
-/** A typed reference to a backend action a field can invoke (client posts payload back). */
+/**
+ * A typed reference to a backend action a field can invoke. The client posts it back
+ * (with a payload); the surface id comes from context.
+ */
export interface ActionRef {
readonly actionId: string;
}
@@ -31,12 +58,16 @@ export interface SurfaceOption {
readonly label: string;
}
-/** A field within a surface — a SEMANTIC value, not a widget. `kind` is the discriminant. */
+/**
+ * A field within a surface — a SEMANTIC value, not a widget. `kind` is the
+ * discriminant a client switches on to pick a renderer.
+ */
export type SurfaceField =
| ToggleField
| ProgressField
| SelectorField
| StatField
+ | NumberField
| ButtonField
| CustomField;
@@ -48,7 +79,7 @@ export interface ToggleField {
readonly action: ActionRef;
}
-/** A bounded ratio in [0, 1] with a label. Read-only. */
+/** A bounded ratio in [0, 1] with a label (e.g. a cache-hit rate). Read-only. */
export interface ProgressField {
readonly kind: "progress";
readonly label: string;
@@ -71,6 +102,24 @@ export interface StatField {
readonly value: string;
}
+/**
+ * A settable numeric value plus the action that sets it — the free-value
+ * counterpart to `selector` (which is a fixed enum). Optional `min`/`max`/`step`
+ * are SEMANTIC bounds a client may use to validate/step input; `unit` is a
+ * display hint (e.g. "ms", "min"). The client posts the new number as the action
+ * payload. Unlike `progress`/`stat` (read-only), this field is interactive.
+ */
+export interface NumberField {
+ readonly kind: "number";
+ readonly label: string;
+ readonly value: number;
+ readonly min?: number;
+ readonly max?: number;
+ readonly step?: number;
+ readonly unit?: string;
+ readonly action: ActionRef;
+}
+
/** A labelled action trigger. */
export interface ButtonField {
readonly kind: "button";
@@ -79,8 +128,10 @@ export interface ButtonField {
}
/**
- * The escape hatch: data that fits no semantic field kind. Opaque `payload` + a
- * `rendererId`; clients WITH a renderer for that id show it, others GRACEFULLY SKIP.
+ * The escape hatch: data that fits no semantic field kind. Carries an opaque
+ * `payload` + a `rendererId`; clients WITH a renderer for that id show it, others
+ * GRACEFULLY SKIP. Keep rare — and the owning extension should export a typed
+ * payload type so its bespoke renderer narrows `payload` via a typed symbol.
*/
export interface CustomField {
readonly kind: "custom";
@@ -88,7 +139,10 @@ export interface CustomField {
readonly payload: unknown;
}
-/** A surface: an ordered set of fields mounted in a region, with a title. */
+/**
+ * A surface: an ordered set of fields mounted in a region, with a title. The atomic
+ * unit a backend extension contributes and a client renders.
+ */
export interface SurfaceSpec {
readonly id: string;
readonly region: Region;
@@ -96,43 +150,77 @@ export interface SurfaceSpec {
readonly fields: readonly SurfaceField[];
}
-/** A surface-catalog entry — discovery metadata only (no field data). */
+/**
+ * A surface-catalog entry — discovery metadata only (no field data).
+ */
export interface SurfaceCatalogEntry {
readonly id: string;
readonly region: Region;
readonly title: string;
+ /**
+ * Whether the surface's spec/values differ per conversation ("conversation")
+ * or are app-wide ("global"). A client may skip re-subscribing GLOBAL surfaces
+ * on a conversation switch (they ignore `conversationId`). Optional + additive:
+ * when absent, a client should assume conversation-scoped (the conservative
+ * "always send the focused conversationId" policy still works for both).
+ */
+ readonly scope?: "global" | "conversation";
}
/** The surface catalog: the list of available surfaces a client can choose to show. */
export type SurfaceCatalog = readonly SurfaceCatalogEntry[];
-/** A live update for a subscribed surface. v1 carries the full new spec. */
+/**
+ * A live update for a subscribed surface (pushed over the WS channel). v1 carries
+ * the full new spec (the simplest "patch").
+ *
+ * `conversationId` is present only for a CONVERSATION-SCOPED surface (one whose
+ * spec/values differ per conversation, e.g. cache-warming controls): it tells the
+ * client which conversation this update pertains to. A global surface omits it.
+ */
export interface SurfaceUpdate {
readonly surfaceId: string;
readonly spec: SurfaceSpec;
+ readonly conversationId?: string;
}
-// ── Surface WebSocket protocol (slice 1: surfaces only) ──────────────────────
+// ── Surface WebSocket protocol ────────────────────────────────────────────────
/** A client → server message on the surface channel. */
export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
+/**
+ * Begin receiving live updates for a surface (server replies with `surface`, then `update`s).
+ *
+ * For a CONVERSATION-SCOPED surface, include the `conversationId` whose state you
+ * want — the server resolves the spec for that conversation and pushes its updates.
+ * Omit it for a global surface (or to view a conversation-scoped surface with no
+ * conversation in focus → the surface decides its default/empty state).
+ */
export interface SubscribeMessage {
readonly type: "subscribe";
readonly surfaceId: string;
+ readonly conversationId?: string;
}
+/** Stop receiving updates for a surface (and the same `conversationId`, if scoped). */
export interface UnsubscribeMessage {
readonly type: "unsubscribe";
readonly surfaceId: string;
+ readonly conversationId?: string;
}
-/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */
+/**
+ * Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean, a
+ * `number` field's new number). For a conversation-scoped surface, include the
+ * `conversationId` the action targets.
+ */
export interface InvokeMessage {
readonly type: "invoke";
readonly surfaceId: string;
readonly actionId: string;
readonly payload?: unknown;
+ readonly conversationId?: string;
}
/** A server → client message on the surface channel. */
@@ -148,10 +236,15 @@ export interface CatalogMessage {
readonly catalog: SurfaceCatalog;
}
-/** The full current spec for a surface the client just subscribed to. */
+/**
+ * The full current spec for a surface the client just subscribed to.
+ * `conversationId` echoes the subscribe's conversation for a conversation-scoped
+ * surface (so the client routes it), and is absent for a global surface.
+ */
export interface SurfaceMessage {
readonly type: "surface";
readonly spec: SurfaceSpec;
+ readonly conversationId?: string;
}
/** A live update for a subscribed surface. */
@@ -160,7 +253,7 @@ export interface SurfaceUpdateMessage {
readonly update: SurfaceUpdate;
}
-/** A surface-scoped error. */
+/** A surface-scoped error (e.g. unknown surface id, invoke failed). */
export interface SurfaceErrorMessage {
readonly type: "error";
readonly surfaceId?: string;
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index ccf07bd..b430a45 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,7 +4,59 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]`. Regenerate whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes.
+>
+> **2026-06-27 delta (workspace starring — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a
+> required `starred: boolean` (defaults to `false` on creation). A starred workspace's agents receive
+> PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces
+> (oldest-agent-first within each group). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints
+> (no body; both create-on-miss and return the updated `Workspace`). `PUT /workspaces/:id` does NOT accept a
+> `starred` field. See `backend-handoff.md`.
+>
+> **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus`
+> widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a
+> per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows
+> a loading ring (vs the dots of `active`). See `backend-handoff.md` CR-13.
+>
+> **2026-06-26 delta (vision handoff — ADDITIVE to `[email protected]`, NO version bump):** adds a new
+> `ImageChunk` variant to the `Chunk` union (`{ type: "image", url, mimeType? }` — `url` is a base64 data
+> URL or an `http(s)://` URL) and a transport-facing `ImageInput` (`{ url, mimeType? }`, what a client
+> sends on `ChatRequest.images`; the orchestrator converts each into an `ImageChunk` on the persisted user
+> message). Vision-capable models receive image chunks natively; non-vision models never see them directly
+> — the orchestrator's vision handoff transcribes each to a text description (persisted as a separate
+> `text` chunk in the SAME user message). See `backend-handoff.md` §2j.
+>
+> **2026-06-26 update (image storage — NO type change, behavior only):** `ImageChunk.url` for PERSISTED
+> chunks is now a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by the backend's
+> new `GET /images/:conversationId/:imageId` endpoint (raw bytes + correct Content-Type), NOT a base64 data
+> URL — images are stored on disk under tmp, not in the SQLite conversation store (keeps payloads small).
+> `ImageInput.url` (what a client SENDS on `ChatRequest.images`) is UNCHANGED — still a data URL or
+> `http(s)://` URL; the backend saves it to tmp and returns the compact path in the persisted chunk. A client
+> resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or an
+> absolute URL passes through unchanged. See `backend-handoff.md` §2j.
+>
+> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds
+> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required
+> `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A
+> workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that
+> haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`.
+>
+> **2026-06-25 delta (SSH handoff #1 — ADDITIVE to `[email protected]`, NO version bump):** adds a REQUIRED
+> `defaultComputerId: string | null` on `Workspace` (null = local / no SSH; the computer analog of
+> `defaultCwd`) and two new read-only view types: `Computer` (a discovered `~/.ssh/config` `Host` alias)
+> and `ComputerEntry extends Computer` (a list entry with a `usageCount`). `alias` IS the `computerId`
+> users select (persisted per-conversation/per-workspace like cwd). The full HTTP API surface
+> (`GET /computers`, `PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`,
+> `GET /computers/:alias/status`, `chat.send computerId`) comes in a LATER handoff — NOT consumed yet.
+>
+> **⚠️ CROSS-REPO DIVERGENCE (2026-06-25, BLOCKING FE typecheck):** the backend `feature/ssh-support`
+> branch (where the SSH types landed) was cut from `8a74335` and is MISSING the `TurnProviderRetryEvent` /
+> `provider-retry` `AgentEvent` addition that is on `dev` (and which the FE already consumes — see §2c of
+> `backend-handoff.md`). The mirror below KEEPS `TurnProviderRetryEvent` (it is the FE's expected contract
+> and matches `dev`); it is marked where it appears. Until the backend merges `dev` into
+> `feature/ssh-support`, the FE pinned to the `feature/ssh-support` wire will NOT typecheck (11 errors,
+> all the missing `provider-retry` seam). The SSH `Computer`/`defaultComputerId` types ARE present on
+> `feature/ssh-support` and are consumed below.
```ts
/**
@@ -23,7 +75,16 @@ export type Role = "system" | "user" | "assistant" | "tool";
/** Opaque identifier for a turn (one user→assistant cycle). */
export type TurnId = string & { readonly __brand: "TurnId" };
-/** Opaque identifier for a step (one LLM round-trip within a turn). */
+/**
+ * Opaque identifier for a step (one LLM round-trip within a turn). It is the
+ * authoritative grouping key for the tool calls a model batches together in a
+ * single step (parallel/batched calls): every `tool-call`/`tool-result` event
+ * and every persisted tool chunk (`ToolCallChunk`/`ToolResultChunk`) from the
+ * same step carries the SAME `stepId`, so a client groups a batch purely by
+ * equality — identically on the live stream and in replayed history. Per-turn
+ * unique and gap-free in step order; treat it as opaque (do not parse it). The
+ * runtime derives it deterministically from the turn id + 0-based step index.
+ */
export type StepId = string & { readonly __brand: "StepId" };
/**
@@ -36,7 +97,8 @@ export type Chunk =
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
- | SystemChunk;
+ | SystemChunk
+ | ImageChunk;
/** A piece of plain text content from the assistant or user. */
export interface TextChunk {
@@ -60,6 +122,18 @@ export interface ToolCallChunk {
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
+ /**
+ * The step that produced this call — generation provenance stamped by the
+ * runtime when the model emits the call (NOT storage metadata like `seq`,
+ * which is why it lives on the chunk and travels with it through persistence
+ * and replay). Tool calls a model batches together in one step share the same
+ * `stepId`: the grouping key for rendering a parallel batch as one unit, and
+ * equal to the `stepId` on the matching `tool-call` AgentEvent. Optional:
+ * absent on chunks reconstructed outside a turn and on rows persisted before
+ * this field existed, so a consumer must tolerate its absence (render
+ * ungrouped).
+ */
+ readonly stepId?: StepId;
}
/**
@@ -73,6 +147,15 @@ export interface ToolResultChunk {
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
+ /**
+ * The step that produced the originating call — equal to the `stepId` on the
+ * matching `tool-call` chunk (same `toolCallId`) and on the `tool-result`
+ * AgentEvent, so a consumer groups a step's calls with their results.
+ * Generation provenance, not storage metadata (see `ToolCallChunk.stepId`).
+ * Optional for the same reasons; `reconcile` copies it from the originating
+ * call onto a synthesized (interrupted) result.
+ */
+ readonly stepId?: StepId;
}
/** An error that occurred during generation or tool dispatch. */
@@ -92,6 +175,51 @@ export interface SystemChunk {
}
/**
+ * An image attached to a message (e.g. a user-pasted screenshot or pasted
+ * photo). Carries a `url` that is EITHER a base64 data URL
+ * (`data:image/png;base64,…`) OR an `http(s)://` URL OR — for PERSISTED chunks
+ * (history/replay) — a compact relative HTTP path (`/images/<conversationId>/
+ * <uuid>.png`) served by the backend's `GET /images/:conversationId/:imageId`
+ * endpoint (images are stored on disk under tmp, NOT in the conversation store,
+ * to keep SQLite payloads small). A client resolves a relative path against its
+ * API base URL; a data URL (the optimistic echo / a pasted image) or an
+ * absolute URL is rendered as-is. Vision-capable models receive it natively
+ * (the provider serializes it to its image-content format); non-vision models
+ * never see it directly — the orchestrator's **vision handoff** transcribes it
+ * to a text description (via a vision-capable model) and feeds that text
+ * instead, so a text-only model can still reason about the image's contents.
+ *
+ * When a transcription was performed, it is persisted as a separate `text`
+ * chunk alongside the `image` chunk in the SAME user message, so the
+ * description is reused on every later turn (no re-transcription) and a
+ * client renders both the original image and its textual analysis.
+ */
+export interface ImageChunk {
+ readonly type: "image";
+ /** Image source: a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or a compact relative path (`/images/<conv>/<uuid>.png`) for persisted chunks. */
+ readonly url: string;
+ /**
+ * Optional MIME type of the image (e.g. `"image/png"`). Inferred from the
+ * data URL when absent; present so a client can render an icon/label without
+ * parsing the URL. Optional — callers that only have a URL omit it.
+ */
+ readonly mimeType?: string;
+}
+
+/**
+ * An image a client attaches to a chat message (`ChatRequest.images`). The
+ * transport-facing input shape; the orchestrator converts each `ImageInput`
+ * into an `ImageChunk` on the persisted user message. Carries the same `url`
+ * semantics as `ImageChunk.url`.
+ */
+export interface ImageInput {
+ /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */
+ readonly url: string;
+ /** Optional MIME type (e.g. `"image/png"`). Optional — inferred from the data URL when absent. */
+ readonly mimeType?: string;
+}
+
+/**
* A chat message: a role plus an ordered sequence of chunks. Messages are the
* unit passed to and from the provider; chunks are the unit persisted and
* rendered.
@@ -103,13 +231,21 @@ export interface ChatMessage {
/**
* A persisted chunk plus its sync metadata. The append-only conversation log
- * stamps every chunk with a monotonic, gap-free, per-conversation `seq` (the
- * sync cursor, assigned in append order) and records the `role` of the message
- * it belongs to. This makes a flat seq-ordered stream both incrementally
- * syncable ("give me chunks after seq N") and regroupable into messages by the
- * client. `chunk` is the pure content unit, unchanged — `Chunk` itself never
- * carries storage metadata (it is also passed to/from the provider, which has
- * no use for a cursor).
+ * stamps every chunk with a **1-based**, monotonic, gap-free, per-conversation
+ * `seq` (the sync cursor, assigned in append order) and records the `role` of
+ * the message it belongs to. This makes a flat seq-ordered stream both
+ * incrementally syncable ("give me chunks after seq N") and regroupable into
+ * messages by the client.
+ *
+ * The 1-based start is a CONTRACTUAL GUARANTEE (not an implementation detail):
+ * a conversation's first chunk is always `seq === 1` and numbering never skips,
+ * so a client holding only a windowed suffix of the log can derive "older
+ * chunks exist server-side" purely from `oldestLoaded.seq > 1` — no separate
+ * has-older flag is needed (or provided). `chunk` is the content unit — `Chunk` carries no storage/sync cursor
+ * (`seq` lives here on the envelope, not on the chunk, since it is assigned by
+ * the store and the provider has no use for it). A chunk MAY still carry
+ * generation provenance assigned at production time (e.g. a tool chunk's
+ * `stepId`), which is intrinsic to the content and so travels with it.
*/
export interface StoredChunk {
readonly seq: number;
@@ -117,6 +253,20 @@ export interface StoredChunk {
readonly chunk: Chunk;
}
+// ─── Reasoning effort ───────────────────────────────────────────────────────
+
+/**
+ * The per-request thinking-depth knob: how much extended thinking / reasoning
+ * the model should spend before answering. Provider-agnostic ladder; each
+ * provider maps a level to its native knob in its own code (e.g. an Anthropic
+ * provider maps it to a `thinking.budget_tokens` value) and MAY ignore levels
+ * (or the field entirely) that its backend cannot express.
+ *
+ * Resolution (owned by the session-orchestrator): per-turn request value →
+ * persisted per-conversation value → default `"high"`.
+ */
+export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
+
// ─── Usage ──────────────────────────────────────────────────────────────────
/**
@@ -130,6 +280,88 @@ export interface Usage {
readonly cacheWriteTokens?: number;
}
+// ─── Persisted metrics ───────────────────────────────────────────────────────
+
+/**
+ * Durable per-step metrics for a completed step — the persisted, replayable
+ * counterpart of the live `usage` + `step-complete` events. Combines the step's
+ * token usage with its generation timing so a client reopening a past
+ * conversation renders the same per-step token/latency breakdown it would have
+ * seen live. Built from the turn's events, stored by `conversation-store`, and
+ * served by `GET /conversations/:id/metrics`.
+ */
+export interface StepMetrics {
+ readonly stepId: StepId;
+ /** The step's token usage (all four counters; cache fields optional per `Usage`). */
+ readonly usage: Usage;
+ /** Time to first token (stream start → first text/reasoning delta). Optional — see `TurnStepCompleteEvent.ttftMs`. */
+ readonly ttftMs?: number;
+ /** Decode time (first token → stream end). Optional — see `TurnStepCompleteEvent.decodeMs`. */
+ readonly decodeMs?: number;
+ /** Total generation time for the step (stream start → stream end). Optional: present only when a clock was available. */
+ readonly genTotalMs?: number;
+}
+
+/**
+ * Durable per-turn metrics for a completed (sealed) turn — the persisted,
+ * replayable counterpart of the live `done` event's aggregate `usage` +
+ * `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all
+ * steps; `steps` carries each step's `StepMetrics` in step order. Persisted per
+ * turn by `conversation-store` (returned in turn-append order) and served by
+ * `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried
+ * on every `AgentEvent`, the join key to the live stream.)
+ */
+export interface TurnMetrics {
+ readonly turnId: string;
+ /** Aggregate token usage across all steps in the turn. */
+ readonly usage: Usage;
+ /** Total wall-clock duration of the turn (turn start → turn end). Optional: present only when a clock was available. */
+ readonly durationMs?: number;
+ /** Per-step metrics in step order. */
+ readonly steps: readonly StepMetrics[];
+ /**
+ * **Context size** — tokens the conversation occupies as of this turn: the
+ * turn's FINAL step `inputTokens + outputTokens` (the last entry of `steps`),
+ * NOT the aggregate `usage` (which sums per-step prompts and overcounts a
+ * multi-step turn). The persisted, replayable counterpart of
+ * `TurnDoneEvent.contextSize` and equal to it for the same turn. A client
+ * reopening a past conversation reads the LAST turn's `contextSize` as the
+ * current context usage. Optional: absent when no per-step usage was available.
+ */
+ readonly contextSize?: number;
+}
+
+// ─── Message queue + steering ───────────────────────────────────────────────
+
+/**
+ * A user message held in a conversation's message queue, awaiting mid-turn
+ * steering delivery. The message-queue extension owns the queue and exposes it
+ * as a per-conversation `custom` surface field; this type is the shared shape
+ * the surface payload, the enqueue response, and the extension's service all
+ * use (so a separate frontend repo can depend on the wire alone to render it).
+ */
+export interface QueuedMessage {
+ /** Stable id (client-visible) for UI keying + dedup. */
+ readonly id: string;
+ /** The message text the client enqueued. */
+ readonly text: string;
+ /** When the message was enqueued (epoch-ms). */
+ readonly queuedAt: number;
+}
+
+/**
+ * The payload of the message-queue extension's per-conversation `custom`
+ * surface field (`rendererId: "message-queue"`): the current queue snapshot a
+ * frontend renders. Carried on the SURFACE channel (NOT the chat stream) — the
+ * queue is control/state, distinct from turn content. An empty `messages`
+ * array means the queue is empty (no pending steering). The frontend moves a
+ * message from this queue surface into the transcript when it is drained (the
+ * surface clears) and/or when the matching `TurnSteeringEvent` arrives.
+ */
+export interface QueuePayload {
+ readonly messages: readonly QueuedMessage[];
+}
+
// ─── Outward events ─────────────────────────────────────────────────────────
/**
@@ -139,15 +371,19 @@ export interface Usage {
export type AgentEvent =
| StatusEvent
| TurnStartEvent
+ | TurnInputEvent
| TurnTextDeltaEvent
| TurnReasoningDeltaEvent
| TurnToolCallEvent
| TurnToolResultEvent
| TurnToolOutputEvent
| TurnUsageEvent
+ | TurnStepCompleteEvent
| TurnErrorEvent
+ | TurnProviderRetryEvent // ⚠️ divergent: present on `dev`, MISSING on the pinned `feature/ssh-support` wire (see header + backend-handoff.md §2c)
| TurnDoneEvent
- | TurnSealedEvent;
+ | TurnSealedEvent
+ | TurnSteeringEvent;
/** Status change for a conversation (e.g. idle → running). */
export interface StatusEvent {
@@ -163,6 +399,25 @@ export interface TurnStartEvent {
readonly turnId: string;
}
+/**
+ * The user prompt that opened this turn, surfaced INTO the turn's outward event
+ * stream. The user message is persisted only when the turn seals (atomically with
+ * the assistant reply), so without this event a client that is merely WATCHING a
+ * conversation (subscribed but not the sender) has no source for the prompt text
+ * mid-turn — it would see the streaming reply with no preceding user bubble until
+ * seal. Emitted once, as the FIRST event of the turn (before `turn-start`), so it
+ * is buffered and replayed to every subscriber — live and late-join — exactly like
+ * the rest of the turn. The sender already echoes its own prompt optimistically, so
+ * a consumer should de-dup against that (e.g. by text); a pure watcher renders it
+ * directly. Carries the raw prompt `text` (the same text passed to the provider).
+ */
+export interface TurnInputEvent {
+ readonly type: "user-message";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly text: string;
+}
+
/** Incremental text content from the model during a turn. */
export interface TurnTextDeltaEvent {
readonly type: "text-delta";
@@ -184,6 +439,14 @@ export interface TurnToolCallEvent {
readonly type: "tool-call";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step that produced this call. Tool calls a model batches together in
+ * one step share the same `stepId` — the grouping key for rendering a
+ * parallel batch as one unit. Matches the `stepId` on the matching
+ * `tool-result` event and on the persisted tool chunk
+ * (`StoredChunk.chunk.stepId`).
+ */
+ readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly input: unknown;
@@ -194,10 +457,23 @@ export interface TurnToolResultEvent {
readonly type: "tool-result";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step that produced the originating call. Equal to the `stepId` on the
+ * matching `tool-call` event (same `toolCallId`) and on the persisted tool
+ * chunk (`StoredChunk.chunk.stepId`), so a client groups a step's calls with
+ * their results.
+ */
+ readonly stepId: StepId;
readonly toolCallId: string;
readonly toolName: string;
readonly content: string;
readonly isError: boolean;
+ /**
+ * How long the tool took to execute (dispatch → result), in milliseconds —
+ * the backend's authoritative execution time, distinct from any client-side
+ * wall-clock. Optional: present only when the runtime was given a clock.
+ */
+ readonly durationMs?: number;
}
/** Streaming output from a tool execution (e.g. shell stdout/stderr). */
@@ -215,9 +491,43 @@ export interface TurnUsageEvent {
readonly type: "usage";
readonly conversationId: string;
readonly turnId: string;
+ /**
+ * The step this usage report belongs to, so a consumer can attribute tokens
+ * per step (and join with the matching `step-complete` timing by `stepId`).
+ * Optional: absent when the runtime had no step context, and on usage emitted
+ * before this field existed.
+ */
+ readonly stepId?: StepId;
readonly usage: Usage;
}
+/**
+ * A step (one LLM round-trip) has completed — the authoritative per-step metrics
+ * packet, emitted once at the step's end (after the generation stream finishes),
+ * so its timing is final (unlike `usage`, which may arrive mid-stream). Carries
+ * the step's generation timing; join to the step's tokens via `stepId` on the
+ * `usage` event. All timing fields are optional: present only when the runtime
+ * was given a clock, and `ttftMs`/`decodeMs` additionally require that a first
+ * content token (text or reasoning) was observed this step.
+ */
+export interface TurnStepCompleteEvent {
+ readonly type: "step-complete";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly stepId: StepId;
+ /** Time to first token: stream start → first text/reasoning delta. */
+ readonly ttftMs?: number;
+ /** Decode time: first token → stream end (generation total − TTFT). */
+ readonly decodeMs?: number;
+ /**
+ * Total generation time for the step: stream start → stream end. Present
+ * whenever a clock was available, even if no first token was seen (in which
+ * case `ttftMs`/`decodeMs` are absent). When a first token was seen,
+ * `genTotalMs === ttftMs + decodeMs`.
+ */
+ readonly genTotalMs?: number;
+}
+
/** An error occurred during the turn. */
export interface TurnErrorEvent {
readonly type: "error";
@@ -227,12 +537,63 @@ export interface TurnErrorEvent {
readonly code?: string;
}
+/**
+ * A retryable provider error is being retried with backoff. Emitted once per
+ * scheduled retry, BEFORE the sleep, so the UI can show "⚠ Server overloaded —
+ * retrying in 5s…" immediately. TRANSIENT: emitted to the frontend but NOT
+ * persisted into the model's message history (it never pollutes the prompt).
+ *
+ * When the retry budget is exhausted, the existing `error` event is emitted and
+ * the turn seals — so the final failure is still a persisted error. `attempt` is
+ * 0-based (the Nth retry about to happen); `delayMs` is the scheduled sleep
+ * before that retry fires.
+ */
+export interface TurnProviderRetryEvent {
+ readonly type: "provider-retry";
+ readonly conversationId: string;
+ readonly turnId: string;
+ /** 0-based: this is the Nth retry about to happen. */
+ readonly attempt: number;
+ /** ms the client should expect to wait before the retry fires. */
+ readonly delayMs: number;
+ /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */
+ readonly message: string;
+ /** The HTTP code when known (e.g. "429"). */
+ readonly code?: string;
+}
+
/** The turn has completed (model finished generating). */
export interface TurnDoneEvent {
readonly type: "done";
readonly conversationId: string;
readonly turnId: string;
readonly reason: string;
+ /**
+ * Total wall-clock duration of the turn (turn start → turn end), in
+ * milliseconds. Optional: present only when the runtime was given a clock.
+ */
+ readonly durationMs?: number;
+ /**
+ * Aggregate token usage across all steps in the turn — a convenience total so
+ * a consumer need not sum the per-step `usage` events. Optional (absent if the
+ * provider reported no usage).
+ */
+ readonly usage?: Usage;
+ /**
+ * **Context size** — the number of tokens the conversation now occupies: this
+ * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full
+ * prompt sent into the last LLM round-trip plus that round-trip's output). This
+ * is the "tokens in context" figure a client renders as the chat's current
+ * context usage, and a client treats the LATEST turn's value as the live total.
+ *
+ * Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's
+ * `inputTokens`, which overcounts a multi-step / tool-calling turn because every
+ * step re-prefills the growing prompt — the final step's input already includes
+ * all prior context, so its input+output is the true occupancy. Optional: absent
+ * when no per-step usage was observed this turn (mirrors `usage`). A later field
+ * will carry the model's max context-window LIMIT; this is only the current size.
+ */
+ readonly contextSize?: number;
}
/**
@@ -244,4 +605,176 @@ export interface TurnSealedEvent {
readonly conversationId: string;
readonly turnId: string;
}
+
+/**
+ * A steering message was injected into an in-flight turn at the tool-result
+ * boundary (the model sees it alongside the tool results and may adjust
+ * course). Drawn from the conversation's message queue (which the drain
+ * clears); the cleared queue arrives as a message-queue SURFACE update, while
+ * THIS event carries the injected `text` so a frontend can place a user bubble
+ * in the transcript live — and so a late-joining watcher sees it before seal
+ * (mirroring `TurnInputEvent` for the opening prompt; emitted into the
+ * in-flight buffer by the session-orchestrator).
+ *
+ * Emitted by the session-orchestrator (in its `drainSteering` wrapper) only
+ * when the kernel drained a non-empty queue at a tool-result boundary. If the
+ * turn instead ENDS with a non-empty queue (no tool call fired), the queue is
+ * carried into a NEW turn whose opening `user-message` event covers the
+ * transcript — so no `steering` event is emitted in that case. One `steering`
+ * event per drain; the combined text of all drained messages.
+ */
+export interface TurnSteeringEvent {
+ readonly type: "steering";
+ readonly conversationId: string;
+ readonly turnId: string;
+ readonly text: string;
+}
+
+// ─── Conversation metadata ───────────────────────────────────────────────────
+
+/**
+ * The lifecycle status of a conversation, used for tab persistence across
+ * devices. `active` = an agent is currently generating; `queued` = the turn is
+ * in flight but waiting for a per-provider concurrency slot (broadcast-only,
+ * never persisted — CR-13; the tab shows a ring vs the dots of `active`);
+ * `idle` = exists but not generating; `closed` = user dismissed the tab
+ * (hidden from the tab bar, not deleted). New conversations start as `idle`;
+ * transitions to `active` on turn-start (or `queued` when the request blocks on
+ * a concurrency slot before generation begins), back to `idle` on turn
+ * done/error, and to `closed` on user close.
+ */
+export type ConversationStatus = "active" | "queued" | "idle" | "closed";
+
+/**
+ * Metadata for a conversation, returned by `GET /conversations` (the list
+ * endpoint). The title defaults to the first user message (truncated) and can
+ * be set via `PUT /conversations/:id/title`. `createdAt` is set on first write;
+ * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle
+ * for cross-device persistence.
+ */
+export interface ConversationMeta {
+ readonly id: string;
+ readonly createdAt: number;
+ readonly lastActivityAt: number;
+ readonly title: string;
+ readonly status: ConversationStatus;
+ /**
+ * The workspace this conversation belongs to. Always present; reads as
+ * `"default"` for legacy conversations that were never explicitly assigned.
+ * Conversations created with no `workspaceId` default to `"default"`.
+ */
+ readonly workspaceId: string;
+ /**
+ * Set on a compacted conversation: points to the archive conversation ID
+ * that holds the full pre-compaction history. Absent on conversations
+ * that have never been compacted.
+ */
+ readonly compactedFrom?: string;
+}
+
+// ─── Compaction ──────────────────────────────────────────────────────────────
+
+/**
+ * Result of a compaction operation. `summary` is the text the model produced;
+ * `messagesKept` is how many recent messages were retained after the summary;
+ * `messagesSummarized` is how many old messages were replaced by the summary.
+ * `newConversationId` is the ID of the new conversation that holds the full
+ * pre-compaction history (non-destructive — the original history is preserved).
+ */
+export interface CompactionResult {
+ readonly summary: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
+}
+
+// ─── Workspaces ──────────────────────────────────────────────────────────────
+
+/**
+ * A named, URL-driven grouping of conversations that owns a default cwd.
+ * Every conversation belongs to exactly one workspace; conversations that
+ * haven't set their own per-conversation cwd inherit `defaultCwd`.
+ *
+ * Workspaces are backend-owned (so cross-device just works): the workspace
+ * entity and each conversation's `workspaceId` live server-side. The
+ * `"default"` workspace is always present and non-deletable; conversations
+ * created with no `workspaceId` are assigned to `"default"`.
+ */
+export interface Workspace {
+ /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */
+ readonly id: string;
+ /** Display title (editable). Defaults to `id` on creation. */
+ readonly title: string;
+ /** The workspace's default cwd, or `null` (fall through to server default). */
+ readonly defaultCwd: string | null;
+ /**
+ * The workspace's default computer — an SSH config `Host` alias that
+ * conversations in this workspace inherit when they set no `computerId` of
+ * their own. `null` means local (no SSH; today's behavior). The computer
+ * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer`
+ * (per-conv `computerId` → this → `null`/local).
+ */
+ readonly defaultComputerId: string | null;
+ /**
+ * Whether the workspace is starred by the user. Starred workspaces receive
+ * PRIORITY in the concurrency limiter queue — their agents jump ahead
+ * of agents from non-starred workspaces (oldest-agent-first within each group).
+ * Defaults to `false` on creation.
+ */
+ readonly starred: boolean;
+ /** Epoch-ms when the workspace was first created. */
+ readonly createdAt: number;
+ /** Epoch-ms of the most recent conversation activity in this workspace. */
+ readonly lastActivityAt: number;
+}
+
+/**
+ * A workspace entry in the list response (`GET /workspaces`) — a `Workspace`
+ * plus a conversation count.
+ */
+export interface WorkspaceEntry extends Workspace {
+ /** Number of conversations assigned to this workspace. */
+ readonly conversationCount: number;
+}
+
+// ─── Computers ───────────────────────────────────────────────────────────────
+
+/**
+ * A read-only view of a remote computer discovered from the system's
+ * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity
+ * (there is no Computer CRUD store). To add a computer, the user adds a `Host`
+ * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()`
+ * read. Every field below is resolved from the config (first-match-wins for
+ * `HostName`/`User`/`Port`/`IdentityFile`).
+ *
+ * `alias` is the `computerId` users select — the string persisted per
+ * conversation and per workspace (the computer analog of `cwd`). `knownHost`
+ * drives the frontend "known/new" indicator and is read-only.
+ */
+export interface Computer {
+ /** The SSH config `Host` alias — also the `computerId` users select. */
+ readonly alias: string;
+ /** Resolved `HostName`/IP from the config (falls back to the alias itself). */
+ readonly hostName: string;
+ /** Resolved port (config `Port`, default 22). */
+ readonly port: number;
+ /** Resolved user (config `User`, default the current user). */
+ readonly user: string;
+ /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */
+ readonly identityFile: string | null;
+ /**
+ * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously
+ * connected). Drives the frontend "known/new" indicator. Read-only.
+ */
+ readonly knownHost: boolean;
+}
+
+/**
+ * A computer entry in the list response (`GET /computers`) — a `Computer` plus
+ * a usage count. Parallel to `WorkspaceEntry`.
+ */
+export interface ComputerEntry extends Computer {
+ /** Number of conversations/workspaces whose `computerId` resolves to this alias. */
+ readonly usageCount: number;
+}
```
diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..f13cae8
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,2 @@
+VITE_HTTP_PORT=24203
+VITE_WS_PORT=24205
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..dfd8d2b
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,2 @@
+VITE_HTTP_PORT=24991
+VITE_WS_PORT=24990
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/.tool-versions b/.tool-versions
new file mode 100644
index 0000000..bc3d15a
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1 @@
+nodejs 24.16.0
diff --git a/AGENTS.md b/AGENTS.md
index 9f077ba..8636b61 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,74 +1,156 @@
-# Dispatch Web — Constitution (root AGENTS.md)
+# Dispatch Web — Agent Guide (root AGENTS.md)
-> Loaded every session. Non-obvious, project-specific rules only. If a fresh
-> frontier model could infer it from the code, it is NOT here (P6).
-> Full design + rationale: `../arch-rewrite/notes/frontend-design.md` (and the
+> Loaded every session — the single source of truth for working in this repo: the
+> build constitution (code rules) + the workflow. Non-obvious, project-specific rules
+> only — if a fresh frontier model could infer it from the code, it is NOT here (P6).
+> Full design + rationale: `../backend/notes/frontend-design.md` (and the
> backend's `notes/restructure-plan.md` §1 for P1–P8).
+>
+> **You are the single agent for this repo.** You plan, author the cross-unit
+> contracts/types, write the feature code, verify, keep the build green, and keep the
+> living backend handoff current. There is no separate orchestrator and no summoned
+> sub-agents — you do all of it yourself.
## What this is
The **web frontend** for Dispatch — a SEPARATE repo from the backend
-(`../arch-rewrite`). It is a **thin shell + pure feature libraries + a surface
+(`../backend`). It is a **thin shell + pure feature libraries + a surface
host**, NOT a default-SvelteKit ball of mud. It consumes the backend's typed
-contracts (`@dispatch/ui-contract` + the wire types) over HTTP + a WebSocket.
-There is **no mandatory "chat is the root"**: the app is a COMPOSITION of feature
-modules + surfaces, assembled at the composition root; legitimate frontends may
-compose without chat at all.
+contracts (`@dispatch/ui-contract` + the wire types) over HTTP + a WebSocket. The app
+is a COMPOSITION of feature modules + surfaces, assembled at the composition root
+(`src/app/`); there is **no mandatory "chat is the root"** — legitimate frontends may
+compose without chat at all. Built with the backend's methodology: pure core / inject
+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.
+## Repo geography
+```
+src/app/ composition root (imports + wires feature modules + surface host)
+src/core/ PURE: transcript · cache · surfaces (interpreter) · protocol · wire
+src/features/<unit>/ logic/ (pure) · ui/ (svelte) · adapter/ (effects)
+src/adapters/ injected browser effects: WS client, fetch, IndexedDB, history
+.dispatch/ mirrored backend contracts (*.reference.md) + rules/
+reports/ (gitignored)
+```
+Backend (SEPARATE repo, contracts only): `../backend` — consume
+`@dispatch/ui-contract` (`file:` dep) + the wire types. Do NOT edit it.
+
## The non-negotiable rules
-- **Pure core / injected shell.** Decision logic (reducers, view-models,
- formatters, parsers) is pure `input → output`: zero DOM, zero `fetch`/WebSocket,
- zero Svelte import. Effects (WebSocket, fetch, IndexedDB, history, clipboard)
- are INJECTED at the edges. State is a pure reducer; Svelte runes are a THIN
- reactive wrapper over it, never the home of logic.
-- **No ambient state.** State is owned per-unit and passed explicitly. No
- module-global mutable store reached from everywhere — that is the old FE's
- "tools leak across tabs" / "model resets on tab switch" bug class.
-- **Components are thin.** A `.svelte` file wires props/events to pure logic and
- renders; it holds no business logic.
-- **One owner per unit.** Each feature module / file has exactly ONE editing
- agent. To change another unit you report the need up — you do not edit it.
-- **Contracts are the only cross-unit surface.** You see other units' public
- exports (`index.ts`) and the imported `@dispatch/ui-contract` / wire types —
- never their internals. Needing internals ⇒ the contract is incomplete; report it.
-- **The surface interpreter is GENERIC.** It switches on field KINDS
- (toggle/progress/...), NEVER on a surface id. No `if (surface.id === "...")` —
- that imports a feature's identity and breaks isolation.
-- **Typed coupling.** Cross-feature links are typed imports/callbacks; no
- stringly-typed event bus. Discovery-by-id (surface catalog, subscribe) is
- sanctioned DATA flow, not a code reference.
+- **Pure core / injected shell.** Decision logic (reducers, view-models, formatters,
+ parsers) is pure `input → output`: zero DOM, zero `fetch`/WebSocket, zero Svelte
+ import. Effects (WebSocket, fetch, IndexedDB, history, clipboard) are INJECTED at the
+ edges. State is a pure reducer; Svelte runes are a THIN reactive wrapper over it,
+ never the home of logic.
+- **No ambient state.** State is owned per-unit and passed explicitly. No module-global
+ mutable store reached from everywhere — that is the old FE's "tools leak across tabs" /
+ "model resets on tab switch" bug class. Subscriptions are owned and disposed on unmount.
+- **Components are thin.** A `.svelte` file wires props/events to pure logic and renders;
+ it holds no business logic.
+- **Contracts are the cross-unit surface.** Cross-unit dependencies go through a unit's
+ public exports (`index.ts`) + the imported `@dispatch/ui-contract` / wire types — not
+ another unit's internals. If you find yourself needing a sibling's internals, the
+ contract is incomplete: fix the boundary, don't reach in.
+- **The surface interpreter is GENERIC.** It switches on field KINDS (toggle/progress/…),
+ NEVER on a surface id. No `if (surface.id === "…")` — that imports a feature's identity
+ and breaks isolation. An unknown field kind / unregistered `custom` renderer → graceful
+ skip, never a crash.
+- **Typed coupling.** Cross-feature links are typed imports/callbacks; no stringly-typed
+ event bus. Discovery-by-id (surface catalog, subscribe) is sanctioned DATA flow, not a
+ code reference.
+- biome covers `.ts`/`.js`; `.svelte` correctness is `svelte-check`'s job.
+
+## Workflow
+1. Plan the change; split a big change into dependency-ordered steps — build the shared
+ contract/types first, then the producers, then the consumer / composition root.
+2. Overlap/vocab check vs `GLOSSARY.md` before naming anything new — ask the user before
+ coining a term (see Vocabulary).
+3. Author/extend the cross-unit seam (the shared type/port/`index.ts` export) before
+ writing the code that crosses it.
+4. Write it: pure logic in `logic/` (zero DOM/fetch/Svelte), thin `.svelte` in `ui/`,
+ effects injected at the edges (`adapter/` or props). Tests alongside.
+5. Verify yourself (below).
+6. Commit the milestone; update progress + the living handoff (see Backend seam).
+
+## Verification (run these yourself — trust the green, not intent)
+```
+bun run typecheck # svelte-check — 0 errors
+bun run test # vitest — note the pass count
+bun run check # biome (.ts/.js) — clean
+bun run build # vite build — succeeds
+git status --short # confirm you only touched what you meant to
+```
+Asymmetric testing: pure logic → vitest with NO internal `vi.mock` of our own modules;
+components → a few `@testing-library/svelte` tests (fake only the OUTERMOST edge —
+socket/fetch/clock, never a sibling module). Re-run the suite TWICE when a change touches
+effects backed by a shared global (`fake-indexeddb`, `localStorage`) to catch cross-test
+pollution. After a slice that touches the wire or browser effects, run a LIVE probe (below).
## Backend seam (cross-repo)
-The backend is `../arch-rewrite` (separate repo; `lsp references` does NOT span
-the boundary). You consume `@dispatch/ui-contract` (surfaces) + the wire types as
-a pinned dependency. Need a backend contract change? REPORT IT UP — the
-orchestrator carries it across (couriered via the user). Never reach into the
-backend repo.
+The backend is `../backend` (separate repo; `lsp references` does NOT span the
+boundary). You consume `@dispatch/ui-contract` + the wire/transport types as pinned `file:`
+deps. **Read the in-repo mirrors `.dispatch/*.reference.md`, never `node_modules/@dispatch/*`**
+(they symlink out of the repo); regenerate the relevant mirror whenever a contract changes.
+- **FE contract change** (a shared FE type / service handle): edit it, run `lsp references`,
+ and update every consumer yourself.
+- **Backend contract change:** `lsp` does not span repos — REPORT IT UP via the living
+ handoff `backend-handoff.md` (repo root, tracked); the user couriers it to the backend and
+ brings the reply back. On the new version: re-pin the `file:` dep → re-mirror the relevant
+ `.dispatch/*.reference.md` → update FE consumers. NEVER edit the backend repo.
+
+Keep `backend-handoff.md` current at every milestone: FE slice status, pinned contract
+versions + mirrors, open asks / roadblocks for the backend, findings, and likely next asks.
## Surfaces (the modular UI mechanism)
-A **surface** is backend-declared, frontend-agnostic data (fields + values +
-actions), rendered generically. See `frontend-design.md` §4. You render surfaces;
-you never special-case a specific one.
+A **surface** is backend-declared, frontend-agnostic data (fields + values + actions),
+rendered generically. See `frontend-design.md` §4. You render surfaces; you never
+special-case a specific one.
+
+## Live integration probe (effectful seams hide bugs unit tests can't)
+Pure/unit green is necessary, NOT sufficient: a transport/storage SHELL only fails for real
+against a running backend (the slice-1 WS-upgrade bug, and the secure-context
+`crypto.randomUUID` blank page, only surfaced live). After a slice that touches the wire or
+browser effects, run a LIVE probe:
+- `scripts/live-probe.ts` (`bun scripts/live-probe.ts`) drives the REAL network-facing modules
+ (`adapters/ws` + `core/chunks` + cache + HTTP sync) against the running backend. Keep it OUT
+ of `bun run test` so a down backend never reds CI. It runs in Bun (global `WebSocket`/`fetch`);
+ use `fake-indexeddb/auto` for IndexedDB.
+- The backend is the USER's process — never boot it yourself (headless boot+probe HANGS + leaks
+ servers, EADDRINUSE). Confirm reachability first (`curl :24203/health`), then CONNECT. Probe
+ with the REAL env (never an overridden empty key).
+- Browser-only bugs (secure-context APIs, theme, layout) still need a human at the page — list
+ the exact things to click and ask the user to confirm.
## Commands
- `bun run typecheck` — svelte-check
- `bun run test` — vitest
- `bun run check` — biome (`.ts`/`.js`)
- `bun run build` — vite build
+- `bun run dev` — Vite dev server (port 24204). Full stack: backend `bin/up`
+ (HTTP :24203, surface WS :24205) + FE Vite :24204.
+
+## Status
+Slices 1–3 DONE + committed (surface system + WS; conversation transcript cache + delta
+streaming; tabs + model selector + DaisyUI/dracula), plus per-conversation cwd + LSP view,
+context size, cache-warming (+ retention/timer), markdown, smart auto-scroll, multi-client
+live view (subscribe/reconnect + the user prompt on the event stream), and the chat limit
+(bulk quarter-unload past `dispatch.chatLimit`, 75% fresh-load window, show-earlier page-in;
+`core/chunks/trim.ts`; CR-5 `?limit=`/`?beforeSeq=` CONSUMED — server-windowed cold loads +
+show-earlier server backfill; `hasOlder` from the 1-based gap-free seq contract), and the
+reasoning-effort selector (Model view, under the provider/model dropdowns; sticky per-conversation
+`GET`/`PUT /reasoning-effort`, `null` ⇒ "high (default)"). Plan in
+`../backend/notes/frontend-design.md` §10.
## Reports
-Finish a task → write `reports/<your-unit>.md` (gitignored): what you built, the
-public surface, test/typecheck/build output, and any contract gaps / change-
-requests (incl. backend-contract CRs for the orchestrator to courier).
+Optionally record a finished milestone in `reports/<name>.md` (gitignored): what you built,
+the public surface, verification output, and any contract gaps / backend-contract CRs.
## Vocabulary
-Use `GLOSSARY.md`. Shared backend terms are canonical (conversation, turn, step,
-chunk, AgentEvent, model name, …). FE terms: surface, region, field kind,
-action / action ref, surface catalog. **"view" is RESERVED** (old-Dispatch sidebar
-UX, future). Never invent a synonym.
+Use `GLOSSARY.md`. Shared backend terms are canonical (conversation, turn, step, chunk,
+AgentEvent, model name, …). FE terms: surface, region, field kind, action / action ref,
+surface catalog. **"view" is RESERVED** (old-Dispatch sidebar UX, future). Never invent a
+synonym.
diff --git a/GLOSSARY.md b/GLOSSARY.md
index 2d25fd3..4ca083c 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -1,4 +1,4 @@
-# Glossary — canonical vocabulary (dispatch-web)
+# Glossary — canonical vocabulary (frontend)
> One name per concept. Shared backend terms are adopted VERBATIM (no drift).
> New term? The orchestrator proposes the standard name and the user confirms
@@ -15,6 +15,21 @@
| **AgentEvent** | An outward event the runtime emits during a turn (text-delta, tool-call, usage, done, turn-sealed, …). | — |
| **model name** | The selectable id in `<credentialName>/<model>` form. | model id, model reference |
| **model catalog** | The list of available model names. | model list |
+| **turn metrics** | The durable, replayable per-turn metrics record for a sealed turn: aggregate `Usage` (tokens) + turn `durationMs` + its per-step `StepMetrics` (`TurnMetrics`). Persisted backend-side keyed by `turnId`, served by `GET /conversations/:id/metrics`. The persisted counterpart of the live `done` event's metrics; the FE folds the SAME shape from the live `usage`/`step-complete`/`done` events for the in-flight turn. | usage record, turn stats |
+| **step metrics** | The durable per-step metrics within a `TurnMetrics`: the step's `Usage` (tokens) + `ttftMs`/`decodeMs`/`genTotalMs` timing, keyed by `stepId` (`StepMetrics`). The persisted counterpart of the live `usage` + `step-complete` events. | step stats |
+| **TTFT** (time to first token) | Per-step latency: generation stream start → first content token (text or reasoning). One per step (each step re-prefills). On the wire as `step-complete.ttftMs` / `StepMetrics.ttftMs` (optional). | time-to-first-byte |
+| **decode time** | Per-step generation time after the first token (first token → stream end = `genTotalMs − ttftMs`). On the wire as `step-complete.decodeMs` / `StepMetrics.decodeMs` (optional). | — |
+| **context size** | The tokens a conversation currently occupies: the most recent turn's FINAL step `inputTokens + outputTokens` (NOT the aggregate per-turn `usage`, which sums per-step prompts and overcounts a multi-step turn). On the wire as `TurnDoneEvent.contextSize` (live `done`) + `TurnMetrics.contextSize` (persisted); the FE reads the LATEST turn's value as current usage, and treats `undefined` as "unknown" (renders a placeholder, never `0`). Mirrors the backend GLOSSARY. | context usage, context length, tokens used (and do NOT call it "context window" — that's the limit) |
+| **reasoning effort** | The per-request thinking-depth knob: how much extended thinking the model spends before answering. Canonical ladder `ReasoningEffort = "low" \| "medium" \| "high" \| "xhigh" \| "max"` (`[email protected]`). Resolution is SERVER-owned (never re-implement): per-turn `ChatRequest.reasoningEffort` override → persisted per-conversation value (`GET`/`PUT /conversations/:id/reasoning-effort`) → default `"high"` — so `null` from the GET means "default (`high`) applies", not "off". Changing the level can bust the prompt cache for the next turn (one-time re-prefill); a stable setting stays cache-safe. | thinking setting, thinking level, effort level, thinking budget |
+| **context window** | The model's MAXIMUM token capacity (the limit a **context size** is measured against). A FUTURE backend field — not on the wire yet. **Placeholder:** the composer status bar currently HARDCODES a `1,000,000`-token window for the `size / limit · pct%` readout + fill bar; swap to the real per-model value when the backend ships it (see `backend-handoff.md` §3). | max context, token limit (distinct from **context size**, the current usage) |
+| **message queue** | A per-conversation buffer of user messages awaiting mid-turn **steering** delivery (owned by the `message-queue` backend extension). Transient + in-memory; exposed to the FE as a per-conversation **surface** (`message-queue`, scope `conversation`; one `custom` field, `rendererId: "message-queue"`, `payload: QueuePayload`). NOT on the chat stream — it is control/state. Enqueued via `chat.queue` (WS) or `POST /conversations/:id/queue` (HTTP); auto-starts a turn if idle. `[email protected]`. | pending messages, steering queue |
+| **steering** | A user message injected into an in-flight turn at the tool-result boundary (drawn from the **message queue**): the model sees it alongside the tool results and may adjust course. Emitted on the chat stream as a `steering` `AgentEvent` (`TurnSteeringEvent`); the queue surface clears on drain (move, don't duplicate). If the turn ends with a non-empty queue (no tool call fired), the queue carries into a NEW turn as its opening prompt (no `steering` event). `[email protected]`. | mid-turn injection, course correction, interruption |
+| **computer** | A remote SSH target discovered from the system's `~/.ssh/config` — a read-only VIEW, NOT an editable entity (no CRUD store; to add one the user edits `~/.ssh/config`). Backend-canonical (`[email protected]`, additive). On the wire as `Computer` (`{ alias, hostName, port, user, identityFile, knownHost }`) + `ComputerEntry extends Computer` (adds `usageCount`, for `GET /computers`). `alias` IS the **computerId** — the string persisted per-conversation / per-workspace (the computer analog of `cwd`). Resolution is SERVER-owned (never re-implement): per-conversation `computerId` → `workspace.defaultComputerId` → `null`/local. USER-facing only: a tool-execution target forwarded to tools, NEVER part of the model prompt (does not affect prompt caching); the agent never sees it. HTTP API (`GET /computers`, `GET`/`PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`, `GET /computers/:alias/status`, `POST /computers/:alias/test`) consumed in handoff #2. | ssh host, remote host, server, connection target |
+| **computerId** | The string id of a **computer** — an SSH config `Host` alias users select. Persisted per-conversation and per-workspace (the computer analog of `cwd`/`workspaceId`). `null` means local (no SSH; today's behavior). On `Workspace` as the REQUIRED `defaultComputerId: string \| null` (null = local / no SSH; the computer analog of `defaultCwd`); per-conversation persistence via `GET`/`PUT`/`DELETE /conversations/:id/computer`. `chat.send` need not send it (resolved server-side from the persisted per-conversation value in the MVP). | ssh alias, host id, remote id |
+| **image chunk** | An `ImageChunk` (`{ type: "image", url, mimeType? }`) — a `Chunk` variant for an image attached to a message (a user-pasted screenshot/photo). `url` is a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or — for PERSISTED chunks — a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by `GET /images/:conversationId/:imageId` (images are stored on disk under tmp, not in the SQLite store). Backend-canonical (`[email protected]`, additive). A user message may be multi-chunk (`[text, image, image, …]` in order). On the wire as `ImageChunk` (persisted) + `ImageInput` (what `ChatRequest.images` carries — still a data URL; the orchestrator saves it to tmp and returns the compact path). A client resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or absolute URL passes through. | picture, photo attachment, screenshot chunk |
+| **vision** (capability) | Whether a model can natively accept image input (multimodal). On the wire as `ModelMetadata.vision?: boolean` (`GET /models` `modelInfo[name].vision`). `true` (e.g. any `kimi/*` model) → image chunks are passed through to the provider natively. Absent/`false` (e.g. `umans/glm-5.2`) → the server's **vision handoff** gives the model a numbered placeholder + the `consult_vision` tool. The FE shows a vision badge in the model picker; it does NOT decide handoff (server-owned). | multimodal, image support |
+| **vision handoff** | The server-owned mechanism by which a NON-vision model still reasons about a pasted image. A non-vision model gets a NUMBERED PLACEHOLDER text chunk alongside the persisted `image` chunk, then calls the `consult_vision` tool (which opens a NEW conversation tab with a vision-capable model, attaches the image + question, and returns the vision model's answer). The persisted user message keeps the original `image` chunk (the FE renders it) AND the placeholder text. When a vision-capable model has more than `imageLimit` images in history, image **compaction** transcribes the oldest to `[Compacted image]: <description>` text chunks (the `image` chunk stays for rendering). All these are regular `text` chunks — the FE renders them as-is. Distinct from a vision-capable model, which receives the image natively. | image transcription, vision relay |
+| **consult_vision** | A tool available to all models that defers image analysis to a vision-capable model: it opens a NEW conversation tab (with a vision model, e.g. Kimi), attaches the image (by `imageIds` from a pasted placeholder, or by `path` from disk) + a `question`, and returns the conversation id + the vision model's answer (suggesting the dispatch CLI for follow-ups). Replaces the former `read_image` tool. Rendered generically like any tool call/result (by `toolName`). | read_image (former), vision tool |
## Frontend-specific
| Term | Meaning | Aliases to avoid |
@@ -29,3 +44,10 @@
| **feature module** | A self-contained FE feature (chat, history explorer, …); feature-as-a-library, composed at the root. | — |
| **composition root** | The single place (`src/app/`) that imports + wires feature modules + the surface host. | — |
| **surface interpreter** | The generic renderer: field kind → component. Knows kinds, never surface ids. | — |
+| **metrics bubble** | The FE chat element that renders a turn's **turn metrics** (one per-turn total) and **step metrics** (one per step) as muted system-style bubbles at a turn's tail. UI presentation of `TurnMetrics`/`StepMetrics`; never a surface. | telemetry bubble, usage bubble, stats bubble |
+| **TPS** (tokens per second) | A FE-DERIVED decode rate: `outputTokens / (decodeMs / 1000)` (per step; per turn over Σ `decodeMs`), falling back to `genTotalMs` when `decodeMs` is absent. The backend-recommended basis (excludes first-token latency). Not carried on the wire; omitted when timing is absent. | throughput |
+| **chat limit** | The max LOADED chunks per conversation (default 256; persisted at localStorage `dispatch.chatLimit`, settable via the sidebar's Settings view) before the oldest quarter is unloaded. Counts **chunks** (committed + provisional + accumulating). Policy in `core/chunks/trim.ts`. | chunk limit, message limit, history limit |
+| **unload** | Drop the oldest COMMITTED chunks from the in-memory transcript (and DOM) past the **chat limit** — in BULK (`ceil(limit/4)` per pass, deferred while the reader is scrolled up), never one-per-delta (old Dispatch's scroll-jump bug). Purely local: the IndexedDB cache and the server keep everything; `TranscriptState.hiddenBeforeSeq` is the watermark. Distinct from the conversation-cache's cross-conversation **eviction**. | evict (reserved for the cross-conversation cache), prune, drop |
+| **show earlier** | The affordance at the top of a transcript with unloaded history ("Show earlier messages"): pages one unload-unit back in — local cache first, then the server (CR-5 `?beforeSeq=&limit=`) when the cache doesn't reach far enough back — preserving the reader's scroll position. Offered whenever the loaded window starts above seq 1 (the [email protected] 1-based gap-free seq contract). | load more, pagination |
+| **workspaces** | A URL-driven grouping of conversations that owns a **default cwd**. The root path `/` lists workspaces; `/<id>` opens one (visiting a nonexistent id creates it — create-on-miss). Each conversation belongs to exactly one workspace; the always-present `"default"` workspace is the fallback for unassigned/legacy conversations. Tabs are scoped per workspace (filtered client-side). Backend-owned (`[email protected]` `Workspace`); a conversation's cwd inherits `workspace.defaultCwd` when its own is unset. Distinct from the per-conversation cwd+LSP module (`features/cwd-lsp`, formerly `features/workspace`). | project, space |
+| **starred** | A workspace flag (`Workspace.starred: boolean`, defaults `false`) that grants its agents PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces, oldest-agent-first within each group. Takes effect immediately for already-queued agents (backend-owned). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints (no body; create-on-miss; `PUT /workspaces/:id` does NOT accept `starred`). The FE sorts starred workspaces to the top of the home list (the display echo of their concurrency priority) and toggles optimistically with error revert. | favorited, pinned (and do NOT call it "priority" — priority is the EFFECT, starred is the FLAG) |
diff --git a/ORCHESTRATOR.md b/ORCHESTRATOR.md
deleted file mode 100644
index eb5e381..0000000
--- a/ORCHESTRATOR.md
+++ /dev/null
@@ -1,209 +0,0 @@
-# ORCHESTRATOR.md — driving dispatch-web (the frontend)
-
-> **You are the orchestrator for the frontend repo.** You do NOT write feature
-> code. You plan, author contracts/config/harness, summon owner-agents (one per
-> unit), verify, resolve, keep the build green. Read fully before acting. Also
-> read: `AGENTS.md` (the FE constitution you enforce), `GLOSSARY.md`,
-> `.dispatch/rules/`, and the design home `../arch-rewrite/notes/frontend-design.md`
-> (+ `../arch-rewrite/notes/restructure-plan.md` §1 for P1–P8). This MIRRORS the
-> backend's `../arch-rewrite/ORCHESTRATOR.md` — read that for the deep rationale.
-
-## 0. Mental model
-The frontend is a **composition of feature modules + a surface host**, built with
-the backend's methodology (pure core / inject effects / no ambient state / typed
-contracts / one owner per unit / asymmetric testing). The team structure is
-isomorphic to the module structure: agents communicate only through contracts. The
-surface system (backend-declared, frontend-agnostic UI) is the modular UI
-mechanism — see `frontend-design.md` §4. No feature (not even chat) is the
-mandatory structural root.
-
-## 1. The golden workflow
-1. Plan the unit(s); split into dependency-topological **waves** of disjoint units, and
- WIDEN each wave where you can (§2a); one agent owns one unit.
-2. Overlap/vocab check vs `GLOSSARY.md` before naming anything new (§5.6 — ask the
- user before coining a term).
-3. Pre-author the cross-unit seam (§3) + write the per-summon TASK to
- `prompts/<unit>.md` (gitignored). RE-READ `.dispatch/rules/` + the §2 scoping map
- before each wave — summon from the files, NOT from memory.
-4. Summon a wave via `opencode run` (§2); disjoint units run in PARALLEL (§2a).
-5. Verify the reports + independently re-run typecheck/test/check/build (§4).
-6. Resolve contract gaps / CRs / agent failures (§5, §5a).
-7. Commit the milestone; update progress + the living handoff (§10).
-
-## 2. Summoning agents (`opencode run`)
-**Working dir:** the repo root `/home/tradam/projects/dispatch/dispatch-web` (so the
-agent's `lsp` tool uses THIS repo's TS server).
-**Model:** `opencode-go/mimo-v2.5-pro` for building.
-**Invocation:** concatenate the brief + the scoped rules + the TASK; redirect output
-to a log file; never use `-f`.
-```bash
-cd /home/tradam/projects/dispatch/dispatch-web && \
-opencode run --dir /home/tradam/projects/dispatch/dispatch-web \
- -m opencode-go/mimo-v2.5-pro \
- "$(cat .dispatch/package-agent.md)
-$(cat .dispatch/rules/frontend-pure-core.md .dispatch/rules/frontend-no-ambient-state.md)
-
-## TASK
-$(cat prompts/<unit>.md)" \
- > reports/<unit>.run.log 2>&1
-```
-**MANDATORY — capture output to a file, never display it** (the stream is huge and
-will crash the harness). Read the agent's `reports/<unit>.md`; `grep`/`tail` the log
-only for a specific error.
-**Run discipline:** do NOT background (no shell `&`); large timeout (e.g. 1800000 ms).
-Each summon is its own FOREGROUND `opencode run`. **Parallelism = emit several summon
-calls in ONE assistant message (concurrent tool calls), one per unit — NOT shell
-backgrounding and NOT `&`.** Parallel summons ONLY for disjoint file sets (single-writer);
-see §2a. `AGENTS.md` is auto-loaded by opencode — never `cat` it.
-
-**GOTCHA — headless cross-repo read = HANG.** An agent's Read of any file OUTSIDE `--dir`
-(here `dispatch-web/`) triggers a permission prompt that CANNOT be answered headlessly → the
-run wedges until aborted. The `@dispatch/ui-contract` `file:` dep symlinks OUT of this repo, so
-reading `node_modules/@dispatch/*` hangs. Mitigation (in place): the contract is mirrored in-repo
-under `.dispatch/*.reference.md` — one per consumed backend contract (`ui-contract`, `wire`,
-`transport-contract`) — agents read THOSE; the brief forbids `node_modules/@dispatch/*` reads.
-**Regenerate the relevant snapshot whenever that contract changes** (re-pin → re-mirror → fan out
-consumers), and point `package-agent.md` at every mirror. Agents are told: if you'd need a file
-outside your scope, report it and STOP — never attempt the read.
-
-### `.dispatch/rules/` scoping map (inline ONLY the matching rows)
-- **Every FE agent:** `frontend-pure-core.md`, `frontend-no-ambient-state.md`.
-- **Surface interpreter / renderer / field-component unit:** + `frontend-interpreter-generic.md`.
-- **Transport / protocol / WS-client unit:** + `frontend-inject-transport.md`.
-- **Any unit that builds `.svelte` UI (app shell, chat view/composer, surface field components):** + `frontend-styling.md`.
-
-## 2a. Parallel execution — WAVES
-Throughput comes from running disjoint units at once. Organise it as waves:
-- **A wave = a set of units that (a) touch DISJOINT files and (b) have no compile-time
- dependency on each other** (each imports only already-built units + the pinned contracts).
- Launch a wave by emitting one summon `Bash` call per unit IN A SINGLE MESSAGE (§2). Later
- waves depend on earlier ones; the composition root (`src/app/`) is almost always the LAST wave.
-- **Widen waves deliberately.** Before summoning, look for dependency edges you can remove so a
- unit moves into an earlier (wider) wave — e.g. make an adapter GENERIC so it doesn't import a
- feature's port (a `LocalStore<T>` instead of a tabs-specific store), or have the consumer define
- the port it needs so the producer/consumer split disappears. Fewer edges ⇒ wider waves ⇒ faster.
-- **One writer per file, always** — even across waves. If two units would edit the same file, they
- are NOT separable; merge them into one unit or sequence them.
-- **After a wave:** read every report, run the §4 checks ONCE for the whole wave (not per unit),
- commit the milestone, then start the next wave. Don't interleave a new wave before the prior one
- is green.
-
-## 3. The per-summon `prompts/<unit>.md` is JUST the TASK
-The invariant guardrails live in `package-agent.md` + the inlined rules. The TASK
-states only the non-inferable, project-specific job: your unit's directory; the job
-+ algorithm naming the contract types involved; the contract file(s) to read
-(`.dispatch/*.reference.md`, a sibling's `index.ts`); the required named test cases.
-
-**Pre-author the cross-unit seam.** When two units in the SAME wave must interoperate (a producer
-+ a consumer that never see each other's internals), the orchestrator pins the shared interface in
-BOTH prompts before summoning: name the exact port/type, say who DEFINES it and who IMPORTS it
-(consumer-defines-port is the default — the adapter implements it), and which `index.ts` to import
-from. That precise seam is what lets disjoint, blind units compose on the first try.
-
-**Tell each agent it has company.** Add the concurrency note to every wave prompt: sibling units are
-being built in OTHER dirs right now; `svelte-check`/biome are whole-project, so if a check reports
-errors OUTSIDE your unit's dir, that's concurrent WIP — ignore it and ensure YOUR files are clean.
-The orchestrator's post-wave run (§4) is the source of truth.
-
-**Make agents IMPLEMENT, not deliberate.** A summoned owner must edit files + run its checks + write
-its report in the one run. Prompts should say so explicitly when needed (see §5a).
-
-## 4. Verification (re-run yourself — trust nothing)
-```bash
-cd /home/tradam/projects/dispatch/dispatch-web
-bun run typecheck # svelte-check — 0 errors
-bun run test # vitest — note the pass count
-bun run check # biome (.ts/.js) — clean
-bun run build # vite build — succeeds
-git status --short # confirm the agent stayed in its lane
-```
-Trust = contracts + public surfaces + green checks + the report — NOT reading impl.
-For pure units, confirm tests use NO internal `vi.mock` of our modules.
-
-**Concurrency caveat:** because `svelte-check`/biome/`vitest` are whole-project, an agent's OWN
-verification (mid-wave) can transiently see a sibling's half-written file. Don't act on an agent
-report's out-of-dir errors; YOUR post-wave run is authoritative. **Run the suite TWICE** when a wave
-touched effects backed by a shared global (e.g. `fake-indexeddb`, `localStorage`) — to catch
-cross-test pollution / flakiness before committing.
-
-## 5. Errors, CRs, cross-repo
-- **FE contract change** (a shared FE type / service handle): the owner edits it,
- runs `lsp references`, reports the consumer list; the orchestrator dispatches the
- fan-out.
-- **CR for build/config** (root tsconfig/vite/biome/package.json): the orchestrator
- edits directly. **CR for impl** (a sibling, composition wiring in `src/app/`): the
- orchestrator SUMMONS the owning agent.
-- **BACKEND contract change (cross-repo):** `lsp references` does NOT span the two
- repos. The FE pins `@dispatch/ui-contract` + wire types as a dependency. A needed
- backend change is reported UP and **couriered by the user** to the backend
- orchestrator (via the living handoff, §10); on the new version, re-pin + re-mirror +
- fan out FE consumers. NEVER edit the backend repo.
-
-## 5a. Agent-failure recovery patterns
-- **Plan-only / "shall I proceed?" agent.** A summon sometimes returns a PLAN and stops without
- editing (no diff, no report). Detected via `git status` + a missing `reports/<unit>.md`. Re-summon
- the SAME TASK prefixed with: "IMPLEMENT THIS NOW — make all edits, run the checks, write the
- report; do not stop to plan or ask." Don't hand-fix its work.
-- **Behaviour change reds a SIBLING's tests (test fan-out).** When a unit's new behaviour invalidates
- another unit's test assertions, the failing tests belong to that OTHER owner — summon it with a
- focused "fix these N failing tests to match the new behaviour" TASK (state the behaviour). The
- orchestrator does not edit feature tests itself.
-- **Agent strayed out of its lane.** `git status` after every wave; if an agent touched a file
- outside its dir, decide per §5/§6: keep it if it's legitimately the orchestrator's lane (build/
- config/harness) and note it; otherwise revert + re-summon with a tighter scope. (Seen: an `app`
- agent edited root `vitest-setup.ts` instead of filing a CR — adopted because it's config, but it
- should have been a CR.)
-- **Flaky green.** If a wave's suite passes once but the units use shared-global effects, re-run
- before trusting (see §4).
-
-## 6. Restrictions (NEVER violate)
-- Single-writer: never two agents on one file.
-- The orchestrator never reads/edits feature impl (`.ts`/`.svelte`). It MAY edit:
- (a) locally-mirrored/consumed contract pins, (b) build/config (tsconfig, vite,
- biome, package.json, .gitignore), (c) harness/docs (this file, AGENTS.md,
- GLOSSARY.md, `.dispatch/`, prompts/, reports/). The composition root (`src/app/`)
- changes ONLY via a summoned owner. Roadblock → ask the user.
-- The surface interpreter is GENERIC (no surface-id special-casing).
-- biome covers `.ts`/`.js`; `.svelte` correctness is `svelte-check`'s.
-
-## 7. Repo geography
-```
-/home/tradam/projects/dispatch/dispatch-web (THIS repo)
- AGENTS.md ORCHESTRATOR.md GLOSSARY.md
- .dispatch/{package-agent.md, rules/frontend-*.md}
- src/app/ composition root (imports + wires feature modules + surface host)
- src/core/ PURE: transcript · cache · surfaces (interpreter) · protocol · wire
- src/features/<unit>/ logic/ (pure) · ui/ (svelte) · adapter/ (effects)
- src/adapters/ injected browser effects: WS client, fetch, IndexedDB, history
- prompts/ (gitignored) reports/ (gitignored)
-```
-Backend (SEPARATE repo, contracts only): `/home/tradam/projects/dispatch/arch-rewrite`
-— consume `@dispatch/ui-contract` (`file:` dep) + the wire types. Do NOT edit it.
-
-## 8. Status
-Slices 1–3 DONE + committed (surface system + WS; conversation transcript cache + delta streaming,
-live-verified 9/9; tabs + model selector + DaisyUI/dracula). Plan in
-`../arch-rewrite/notes/frontend-design.md` §10. Dev server: `bun run dev` (port 24204); full stack:
-backend `bin/up` (HTTP :24203, surface WS :24205) + FE Vite :24204.
-
-## 9. Live integration probe (effectful seams hide bugs unit tests can't)
-Pure/unit green is necessary, NOT sufficient: a transport/storage SHELL only fails for real against a
-running backend (the slice-1 WS-upgrade bug, and the secure-context `crypto.randomUUID` blank page,
-only surfaced live). After a slice that touches the wire or browser effects, run a LIVE probe:
-- A gated `scripts/live-probe.ts` (run with `bun scripts/live-probe.ts`) that drives the FE's REAL
- network-facing modules (`adapters/ws` + `core/chunks` + cache + HTTP sync) against `bin/up`. Keep it
- OUT of `bun run test` so a down backend never reds CI. It runs in Bun (global `WebSocket`/`fetch`);
- use `fake-indexeddb/auto` for IndexedDB.
-- The backend is the USER's process (never boot it yourself — headless boot+probe HANGS + leaks
- servers, EADDRINUSE). Just CONNECT to the open ports; confirm reachability first (`curl :24203/
- health`). Probe with the REAL env (never an overridden empty key).
-- Browser-only bugs (secure-context APIs, theme, layout) still need a human at the page — list the
- exact things to click and ask the user to confirm.
-
-## 10. Living cross-repo handoff — `backend-handoff.md`
-`lsp` can't span repos, so the FE↔backend seam flows through ONE rolling doc, `backend-handoff.md`
-(repo root, tracked), kept current so the user can courier the whole seam at any time. It records: FE
-slice status; the pinned contract versions + mirrors; OPEN asks / roadblocks for the backend;
-findings (e.g. "model is per-turn, not persisted per conversation"); and likely NEXT asks. Update it
-at every milestone and whenever a backend need arises; the user carries it across and brings the
-reply back (e.g. `backend-handoff-reply.md`).
diff --git a/README.md b/README.md
index 1d6e60c..8bb564e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Dispatch Web
-The **web frontend** for [Dispatch](../arch-rewrite) — a separate repo built to the same
+The **web frontend** for [Dispatch](../backend) — a separate repo built to the same
methodology (thin shell + pure feature libraries + a backend-driven *surface* host). It consumes
the backend's typed contracts over HTTP + a WebSocket and ships no business logic the backend
doesn't expose.
@@ -17,17 +17,17 @@ doesn't expose.
- [Bun](https://bun.sh) (v1.3+).
- **The backend repo as a sibling directory** — this repo links `@dispatch/ui-contract` from
- `../arch-rewrite` via a `file:` dependency:
+ `../backend` via a `file:` dependency:
```
dispatch/
- arch-rewrite/ # the backend (Dispatch server)
- dispatch-web/ # this repo
+ backend/ # the backend (Dispatch server)
+ frontend/ # this repo
```
-- The **backend server running** for surfaces to appear (see `../arch-rewrite/README.md`).
+- The **backend server running** for surfaces to appear (see `../backend/README.md`).
```sh
-cd dispatch-web
-bun install # links @dispatch/ui-contract from ../arch-rewrite
+cd frontend
+bun install # links @dispatch/ui-contract from ../backend
```
---
@@ -36,17 +36,17 @@ bun install # links @dispatch/ui-contract from ../arch-rewrite
```sh
# 1) start the backend (sibling repo) — HTTP :24203 + surface WS :24205
-cd ../arch-rewrite && bun run dev
+cd ../backend && bun run dev
# 2) start this dev server — Vite on :24204
-cd ../dispatch-web && bun run dev
+cd ../frontend && bun run dev
```
Open **http://localhost:24204**. You'll see the surface catalog (e.g. "Loaded Extensions"); the
frontend connects to the backend's surface WebSocket at `ws://localhost:24205` (override with
`VITE_WS_URL`).
-> **Tip — run both at once with live reload:** the backend repo ships `../arch-rewrite/bin/up`
+> **Tip — run both at once with live reload:** the backend repo ships `../backend/bin/up`
> (also `bun run dev:all` there) which starts the backend (`bun --watch`) + this dev server
> (Vite HMR) together; **Ctrl-C stops both**.
@@ -64,7 +64,7 @@ When browsing from a **different device than the one running the backend**, set
1. **Reach the dev server:** open `http://<this-machine-tailscale-name>:24204`. (The backend's Bun
servers already bind all interfaces, so `:24203`/`:24205` are reachable over Tailscale too.)
2. **Point the frontend at the backend's WebSocket.** The WS URL runs in *your browser*, so
- `localhost` would mean *your* device — set it to the backend host. Create `dispatch-web/.env`:
+ `localhost` would mean *your* device — set it to the backend host. Create `frontend/.env`:
```sh
VITE_WS_URL=ws://<backend-machine-tailscale-name>:24205
```
@@ -99,5 +99,5 @@ bun run check # biome (.ts/.js; .svelte correctness is svelte-check's job
## Documentation
-- **Design + plan:** `../arch-rewrite/notes/frontend-design.md`
-- **Build rules:** `AGENTS.md` · **Orchestration:** `ORCHESTRATOR.md` · **Vocabulary:** `GLOSSARY.md`
+- **Design + plan:** `../backend/notes/frontend-design.md`
+- **Build rules + workflow:** `AGENTS.md` · **Vocabulary:** `GLOSSARY.md`
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..96732d4
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,39 @@
+# Roadmap — frontend
+
+> Living document of shipped + planned FE work. Updated at each milestone.
+> Source of truth for "what's done" + "what's next". Cross-repo handoffs land here
+> as committed work (see `backend-handoff.md` for the living backend seam).
+
+## Done
+
+- **Surface system + WS** — generic surface interpreter, catalog subscribe, `SurfaceView` dispatch on field kind (never surface id); custom renderers for `table`, `message-queue`, `todo`.
+- **Conversation transcript** — chunk cache + delta streaming (`core/chunks`), provisional → committed fold, IndexedDB persistence, chat-limit bulk unload + 75% fresh-load window + show-earlier backfill (CR-5 `?limit=`/`?beforeSeq=`).
+- **Tabs + model selector** — per-conversation tabs (persisted), model catalog, draft → tab promotion on first send.
+- **Multi-client live view** — `chat.subscribe`/`chat.unsubscribe`, structural `generating` flag, resync on reconnect, `user-message` event (CR-3 fix).
+- **Cache-warming** — toggle + interval, manual warm, authoritative `nextWarmAt` timer + retention, `POST /conversations/:id/close` (CR-4 lifecycle).
+- **Per-conversation cwd + LSP** — `GET`/`PUT /cwd`, `GET /lsp` (lazy spawn), draft cwd carries into turn 1.
+- **Context size** — `done.contextSize` + `TurnMetrics.contextSize`, composer fill bar (placeholder 1M limit).
+- **Reasoning effort** — sticky per-conversation thinking-depth knob (`GET`/`PUT /reasoning-effort`, `null` ⇒ default `high`).
+- **Message queue + steering** — `chat.queue` WS op, `steering` AgentEvent → user bubble in transcript, message-queue surface panel above composer.
+- **Todo task list** — `rendererId: "todo"` custom renderer, dedicated "Tasks" sidebar view (status indicators: pending/in_progress/completed/cancelled).
+- **Conversation.open broadcast** — `conversation.open` WS message handler, opens a tab (without auto-switching) from CLI `--open` flag.
+- **Conversation lifecycle (cross-device tab sync)** — `GET /conversations?status=active,idle` on connect restores tabs across devices; `conversation.statusChanged` WS handler updates tab status + removes closed tabs; TabBar shows a spinner on `active` conversations.
+- **Conversation compaction** — "Compaction" sidebar view with manual "Compact now" button (`POST /conversations/:id/compact`) + auto-compact threshold input (`GET`/`PUT /conversations/:id/compact-threshold`); `conversation.compacted` WS handler reloads history.
+- **Workspaces** — URL-driven conversation grouping with a backend-owned default cwd (`[email protected]`/`[email protected]`). Routing: `/` lists workspaces (create-on-visit + delete); `/<id>` opens one (tabs scoped to it, existing tabs migrate to `"default"`). New conversations are stamped with the active workspace on `chat.send`/`chat.queue`. Workspace CRUD via `GET`/`PUT`/`DELETE /workspaces`. *Pending: CwdField explicit-vs-inherited display + clear-to-inherit (`DELETE /conversations/:id/cwd`).*
+
+## Next up
+
+### Conversation list + title editing (`frontend-conversation-list-handoff.md`)
+
+Types already in `[email protected]` + `[email protected]` (mirrored, deps re-pinned). The `conversation.open` + lifecycle handlers are already consumed. Remaining FE work:
+
+1. **Conversation list sidebar view** — `GET /conversations` → `ConversationListResponse` (`ConversationMeta[]` with id, title, createdAt, lastActivityAt, status). Render a "Conversations" sidebar view (title + relative time + status). Click to open (create tab + load history + subscribe). Fetch on mount + on focus / manual refresh. Filter by status (active/idle vs closed for a history view).
+2. **Title editing** — `GET`/`PUT /conversations/:id/title` (`TitleResponse`/`SetTitleRequest`). Inline rename affordance on the active conversation's title. Auto-title from first user message is backend-owned; FE overrides via PUT.
+
+## Backlog (likely next backend asks — not yet requested)
+
+- **Model max context-window LIMIT** — the denominator for the context-size fill bar (currently hardcoded `1_000_000` in `Composer.svelte`). Wire the real per-model `contextWindow` when the backend ships it.
+- **LSP status over WS (push)** — today the FE HTTP-polls `GET /lsp` on mount + manual refresh; a live surface/WS push would remove the manual refresh and reflect server state changes without a reload.
+- **Warming opt-in persistence across backend restarts** — currently fail-safe-off after a restart; backend offered boot hydration if it becomes a need.
+- **Standalone "stop generating" button** — `POST /conversations/:id/close` aborts AND disables warming; a separate "stop without closing" affordance would need its own op if the product wants it.
+- **`GET /conversations/:id/last`** — blocking last-message endpoint (shipped, mirrored, not consumed). Could be used for notification previews or conversation list snippets.
diff --git a/backend-handoff.md b/backend-handoff.md
deleted file mode 100644
index b0b0494..0000000
--- a/backend-handoff.md
+++ /dev/null
@@ -1,97 +0,0 @@
-# Backend handoff — LIVING doc (FE ⇄ backend, couriered by the user)
-
-> **Purpose:** the single rolling document the FE orchestrator keeps current so the user can hand off
-> the whole FE↔backend seam at any time — on completion OR at a roadblock. Updated continuously.
-> **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user.
-> `lsp` does NOT span the repos (ORCHESTRATOR §5) — every cross-repo ask flows through here.
-
-_Last updated: 2026-06-06 — Slice 3 (tabs + model selector + DaisyUI) FE-complete; no new backend asks._
-
----
-
-## 1. Current FE status
-
-| Slice | State |
-|---|---|
-| **Slice 1** — surface system + WS + composition root | ✅ DONE, committed, green. |
-| **Slice 2** — conversation transcript: cache + delta streaming (design §6) | ✅ DONE + **LIVE-VERIFIED** — live e2e probe **9/9** against `bin/up` (see §6). |
-| **Slice 3** — tabs (multi-conversation) + model selector + DaisyUI/dracula | ✅ FE-COMPLETE — svelte-check 0/0, **281 vitest**, biome clean, build ok. Per-tab chat stores, one WS routed by `conversationId`, local-forget on tab close, tabs persisted to localStorage. No backend change needed. |
-
-**Slice 2 units built** (all pure-core / injected-shell, single-owner): `core/chunks` (the one
-transcript reducer) · `core/wire` (contract-conformance drift guard) · `adapters/ws` (now multiplexes
-`chat.send`/`chat.delta` on the one socket) · `features/conversation-cache` (pure reconcile/evict +
-`ConversationChunkStore` port) · `adapters/idb` (IndexedDB impl) · `features/chat` (runes view-model
-+ `ChatView`/`Composer`) · `app` (one socket for surface+chat, host-relative HTTP `:24203` history
-sync, IndexedDB cache, renders the chat). Consumes ONLY the pinned `@0.1.0` contracts — no backend
-change was needed.
-
-## 2. Pinned backend contracts (consumed by the FE)
-
-All three pinned as `file:` deps at **`@0.1.0`** and live-verified consumable (import smoke-test passes):
-
-| Package | Used for |
-|---|---|
-| `@dispatch/ui-contract` | surfaces + surface WS protocol (Slice 1) |
-| `@dispatch/wire` | chat wire types: `Chunk`/`StoredChunk`(+`seq`)/`ChatMessage`/`AgentEvent`/`TurnSealedEvent`/`Usage` |
-| `@dispatch/transport-contract` | HTTP endpoints + `ChatRequest`/`ModelsResponse`/`ConversationHistoryResponse` + WS chat ops + unified `WsClientMessage`/`WsServerMessage` |
-
-Backend endpoints in use (port **24203** HTTP, **24205** WS, CORS wildcard `*` — all confirmed live):
-`POST /chat` (NDJSON), `GET /models`, `GET /conversations/:id?sinceSeq=<n>`, WS `chat.send`→`chat.delta`.
-Confirmed invariants C1–C4 (raw seq-ordered history slice · one path-agnostic WS multiplexing surface+chat · `turn-sealed` fires post-persist = cache-commit · live deltas carry no `seq`).
-
-Mirrored in-repo for headless agents: `.dispatch/ui-contract.reference.md`, `.dispatch/wire.reference.md`,
-`.dispatch/transport-contract.reference.md` (regenerated on any contract bump).
-
-## 3. Open items FOR THE BACKEND
-
-### 3.1 Resolved / answered
-- ✅ Wire-types split, per-chunk `seq`, history endpoint, WS chat multiplexing, CORS — all delivered
- (backend commit `812621c`).
-
-### 3.2a Finding — model is NOT persisted/exposed per conversation (FE handled it)
-`model` is a per-turn `ChatRequest` field only; `session-orchestrator` resolves it per `handleMessage`
-and never stores it, and `conversation-store`/`ConversationHistoryResponse` carry no model. So the FE
-**persists the selected model per tab** (localStorage). No action needed. OPTIONAL future nicety: if
-you ever persist + expose a per-conversation "last model" (e.g. on the `GET /conversations` list when
-it lands), the FE could seed a reopened tab's model from the server instead of localStorage.
-
-### 3.2 FYI — non-blocking gotcha (no action required unless you publish externally)
-- **`workspace:*` breaks external `file:` consumption under bun.** `transport-contract`'s deps are
- `@dispatch/ui-contract`/`@dispatch/wire` at `workspace:*`; `bun install` from dispatch-web could not
- resolve them ("Workspace dependency not found"). **Worked around FE-side** with a `package.json`
- `overrides` block mapping both to their `file:` paths — no backend change needed now. If you ever
- publish these to a registry, prefer real semver ranges over `workspace:*` for out-of-monorepo
- consumers.
-
-### 3.3 Pending asks / roadblocks
-- _(none open)_ — Slice 2 needed no backend change. One coordination item below (§6).
-
-## 6. LIVE end-to-end probe — DONE ✅ (9/9, against `bin/up`)
-
-Ran `bun scripts/live-probe.ts` (drives the FE's REAL network-facing stack — `adapters/ws` socket,
-`core/chunks` reducer, `conversation-cache` + `adapters/idb`, and the HTTP history endpoint — against
-the running backend). **All 9 checks passed:**
-- one WS (`:24205`) delivered the surface `catalog` AND the chat stream;
-- `chat.send` → ~33 `chat.delta` events (incl. `text-delta`) → folded to the expected assistant text
- → `turn-sealed`;
-- post-seal `GET :24203/conversations/:id?sinceSeq=0` → 3 seq-monotonic `StoredChunk`s
- (`latestSeq=3`); `applyHistory` superseded the provisional turn (`sealedTurnId` cleared);
-- IndexedDB cache persisted the sealed turn; committed transcript shows the assistant text.
-
-**No backend mismatch found — every confirmed invariant (C1–C4) held live.** One FE-internal note
-(not a backend matter): the idb adapter relies on the global `IDBKeyRange` (fine in a browser; the
-probe needed `fake-indexeddb/auto` to supply it under Bun).
-
-Also caught + fixed during browser bring-up (FE-only bug, not backend): a BLANK page on plain-HTTP
-non-localhost origins (`http://arch-razer:24204`) because `crypto.randomUUID()` is secure-context-only
-— now replaced with a `getRandomValues`-based fallback.
-
-## 4. (history) Slice 2 unit map — delivered, see §1.
-
-## 5. Likely NEXT backend asks (heads-up, not yet requested)
-
-These belong to **later** FE slices (design §7 "later slice") — flagged early so they're on your radar:
-- `GET /conversations` — conversation list / sidebar (FE history explorer / conversation switcher).
-- `POST /conversations/:id/cancel` — "stop generating".
-
-When the FE reaches those slices, the concrete request will be filed here in §3.3.
diff --git a/biome.json b/biome.json
index 3f6be88..528c1a9 100644
--- a/biome.json
+++ b/biome.json
@@ -1,11 +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"]
- }
+ "$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 a231a98..373ae5b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,9 +5,13 @@
"": {
"name": "dispatch-web",
"dependencies": {
- "@dispatch/transport-contract": "file:../arch-rewrite/packages/transport-contract",
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire",
+ "@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",
},
"devDependencies": {
"@biomejs/biome": "^2.4.16",
@@ -20,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",
@@ -30,8 +36,8 @@
},
},
"overrides": {
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire",
+ "@dispatch/ui-contract": "file:../dispatch-backend/packages/ui-contract",
+ "@dispatch/wire": "file:../dispatch-backend/packages/wire",
},
"packages": {
"@adobe/css-tools": ["@adobe/[email protected]", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
@@ -72,11 +78,11 @@
"@csstools/css-tokenizer": ["@csstools/[email protected]", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
- "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../arch-rewrite/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }],
+ "@dispatch/transport-contract": ["@dispatch/transport-contract@file:../dispatch-backend/packages/transport-contract", { "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*" } }],
- "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}],
+ "@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}],
- "@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}],
+ "@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}],
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
@@ -320,6 +326,8 @@
"dom-accessibility-api": ["[email protected]", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+ "dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
+
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
@@ -370,6 +378,8 @@
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+ "highlight.js": ["[email protected]", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
+
"html-encoding-sniffer": ["[email protected]", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"http-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@@ -426,6 +436,10 @@
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
+
+ "marked-highlight": ["[email protected]", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="],
+
"math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["[email protected]", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -454,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=="],
@@ -546,9 +564,9 @@
"zimmerframe": ["[email protected]", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
- "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../arch-rewrite/packages/ui-contract", {}],
+ "@dispatch/transport-contract/@dispatch/ui-contract": ["@dispatch/ui-contract@file:../dispatch-backend/packages/ui-contract", {}],
- "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../arch-rewrite/packages/wire", {}],
+ "@dispatch/transport-contract/@dispatch/wire": ["@dispatch/wire@file:../dispatch-backend/packages/wire", {}],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/[email protected]", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
diff --git a/index.html b/index.html
index 117e502..337a32e 100644
--- a/index.html
+++ b/index.html
@@ -1,5 +1,5 @@
<!doctype html>
-<html lang="en" data-theme="dracula">
+<html lang="en" data-theme="monokai">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
diff --git a/notes/assumptions-log.md b/notes/assumptions-log.md
new file mode 100644
index 0000000..026cde2
--- /dev/null
+++ b/notes/assumptions-log.md
@@ -0,0 +1,58 @@
+# Assumptions log (frontend)
+
+> Recorded while working autonomously (user away). Raise these when the user returns.
+
+## 2026-06-24 — CR-10: workspaceId on conversation.open / statusChanged
+
+1. **Contract version already at 0.19.0 in the backend repo.** The `file:` dep in `package.json`
+ points to `../arch-rewrite/packages/transport-contract`, which was already at `0.19.0` when the
+ handoff arrived. No `package.json` version string needed changing — `bun install` re-synced the
+ symlink. If the backend repo is later rolled back, the FE will fail to compile (the new required
+ `workspaceId` field won't exist on the type).
+
+2. **The `dist/` directory is owned by root** (likely from a previous root-run build). `bun run build`
+ fails with `EACCES` when Vite tries to empty `dist/assets`. This is an environment issue, not a
+ code issue — I verified the build succeeds with `--outDir dist-tmp` (a fresh directory I own, then
+ removed). The user may want to `sudo chown -R tradam:tradam dist/` or remove it.
+
+3. **Parser now rejects broadcasts missing `workspaceId`.** The WS parser (`logic.ts`) returns `null`
+ for `conversation.open` / `conversation.statusChanged` messages that lack a string `workspaceId`.
+ This is a breaking change for a mixed-version setup (old backend + new FE) — those broadcasts
+ would be silently dropped. Assumed acceptable because the backend contract is updated in lockstep.
+
+4. **Tab is opened but not focused when the conversation's workspace differs from the active one.**
+ The FE creates the tab (stamped with the correct `workspaceId`) but does NOT navigate to that
+ workspace or switch the active tab. The tab is hidden by the `tabs` getter filter
+ (`t.workspaceId === activeWorkspaceId`) until the user navigates to the correct workspace. This
+ matches the original "open without switching" behavior of the `conversation.open` handler. If the
+ product wants auto-navigation to the conversation's workspace on `--open`, that's a separate FE
+ product decision.
+
+5. **CR-10 numbering.** The prior open ask was CR-9 (still open). I assigned this fix CR-10 in the
+ handoff doc for tracking. If the backend used a different CR number, adjust.
+
+## 2026-06-24 — CR-11: Per-conversation model persistence
+
+1. **`refreshModel()` is called on every focus change** (boot, tab switch, workspace switch,
+ reconnect) mirroring `refreshCwd` / `refreshReasoningEffort` / `refreshCompactPercent`. For a
+ draft conversation the `GET /conversations/:id/model` request will 404 (the draft id isn't
+ persisted yet); `res.ok` is false so it's a silent no-op. This matches how the other refresh
+ functions behave for drafts.
+
+2. **`refreshModel` only applies a non-empty string model.** The backend returns `model: null` when
+ never set; the FE leaves `activeModel` unchanged in that case (falls back to the boot default or
+ the last-known tab model). This avoids resetting the selector to `null`/`undefined` and crashing
+ `ModelSelector`'s `splitModelName`. An empty string is also ignored defensively.
+
+3. **`selectModel` persists only for real conversation tabs, not drafts.** A draft has no persisted
+ conversation id yet; the first `chat.send` will carry `model` (the chat store still sends it),
+ and the backend persists it on turn start. Drafts update session-local state only — same as the
+ pre-change behavior.
+
+4. **No "clear model" UI affordance added.** The backend supports `PUT /model` with `{ model: null }`,
+ but the FE model selector has no "reset to default" button. This is a future product decision; the
+ persistence path is ready if/when the UI adds it.
+
+5. **No `model` field on `ConversationMeta`.** Following the precedent of `cwd` and `reasoningEffort`
+ (fetched via dedicated endpoints, not on the list response). The FE fetches model on focus, not
+ from `fetchOpenConversations`.
diff --git a/package.json b/package.json
index 735e278..ea6c751 100644
--- a/package.json
+++ b/package.json
@@ -1,43 +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:../arch-rewrite/packages/transport-contract",
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/packages/wire"
- },
- "overrides": {
- "@dispatch/ui-contract": "file:../arch-rewrite/packages/ui-contract",
- "@dispatch/wire": "file:../arch-rewrite/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/fix-dist-perms.sh b/scripts/fix-dist-perms.sh
new file mode 100755
index 0000000..471cbdf
--- /dev/null
+++ b/scripts/fix-dist-perms.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Fix ownership of dist/ so Vite can clean + rebuild it.
+# The dist/assets/ dir was created as root (likely a Docker build) and Vite
+# can't rmSync it as a non-root user → EACCES on `bun run build`.
+#
+# Usage: sudo ./scripts/fix-dist-perms.sh
+set -euo pipefail
+
+DIST_DIR="$(cd "$(dirname "$0")/.." && pwd)/dist"
+
+if [ ! -d "$DIST_DIR" ]; then
+ echo "No dist/ directory found — nothing to fix."
+ exit 0
+fi
+
+OWNER="$(stat -c '%U:%G' "$DIST_DIR")"
+echo "dist/ is currently owned by: $OWNER"
+
+if [ "$OWNER" = "root:root" ]; then
+ echo "Fixing ownership to $(stat -c '%U:%G' "$(dirname "$DIST_DIR")") ..."
+fi
+
+# chown the whole dist/ tree to the same owner as the repo root
+chown -R --reference="$(dirname "$DIST_DIR")" "$DIST_DIR"
+echo "Done. dist/ is now owned by: $(stat -c '%U:%G' "$DIST_DIR")"
diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts
new file mode 100644
index 0000000..952163c
--- /dev/null
+++ b/scripts/live-probe-provider-retry.ts
@@ -0,0 +1,188 @@
+/**
+ * scripts/live-probe-provider-retry.ts — FOCUSED live probe of the transient
+ * `provider-retry` AgentEvent seam, run against a RUNNING backend (bin/up).
+ * NOT part of `bun run test`.
+ *
+ * A real `provider-retry` only fires on an upstream 429/5xx, which we can't
+ * force from here. So this probe verifies the two things unit tests CAN'T:
+ *
+ * 1. REGRESSION (real wire): a normal text turn through the REAL WS socket +
+ * the updated `foldEvent` (provider-retry case + the reduceEvent wrapper)
+ * seals cleanly and `providerRetry` stays NULL throughout — no spurious
+ * banner, and the wrapper's re-spread didn't break streaming.
+ * 2. PARSER + REDUCER SEAM (the new event's effectful boundary): feed a
+ * synthetic `provider-retry` `chat.delta` JSON string through the REAL
+ * `parseServerMessage` wire parser (the function that runs on every inbound
+ * WS frame) → confirm it is ACCEPTED (not rejected as an unknown event) →
+ * `foldEvent` SETS `providerRetry` (coalesces on a 2nd) and adds NO chunk →
+ * a subsequent `text-delta` CLEARS it. This proves the new variant survives
+ * the JSON-parse boundary the unit tests skip (they pass constructed events).
+ *
+ * bun scripts/live-probe-provider-retry.ts
+ * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts
+ */
+import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract";
+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,
+} from "../src/core/chunks/index.ts";
+
+const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
+const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
+const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok";
+
+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}` : ""}`);
+};
+const fail = (msg: string): never => {
+ 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);
+}
+
+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);
+}
+
+main().catch((e) => fail(String(e)));
diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts
index bc654dd..6121eac 100644
--- a/scripts/live-probe.ts
+++ b/scripts/live-probe.ts
@@ -5,157 +5,400 @@
*
* bun scripts/live-probe.ts # default model
* PROBE_MODEL=opencode/glm-5 bun scripts/live-probe.ts
+ * PROBE_TOOL_PROMPT="..." bun scripts/live-probe.ts # override the tool turn
*
* Drives the FE's REAL network-facing modules (the thin live integration test the
* methodology calls for — the analogue of the backend's server.bun.test.ts):
* - adapters/ws createSurfaceSocket → real WebSocket, one socket multiplexes
- * the surface `catalog` AND chat ops.
- * - core/chunks foldEvent/applyHistory → fold REAL chat.delta AgentEvents.
+ * the surface `catalog` AND chat ops (deltas routed by conversationId).
+ * - core/chunks foldEvent/applyHistory/groupRenderedChunks → fold REAL
+ * chat.delta AgentEvents and group batched tool calls by stepId.
* - features/conversation-cache + adapters/idb (fake-indexeddb) → real cache.
* - HTTP GET /conversations/:id?sinceSeq → real ConversationHistoryResponse.
* Skips the runes chat store + svelte UI (need the Svelte compiler; thin wrappers).
+ *
+ * Turn 1 verifies the text streaming + cache + replay path.
+ * Turn 2 verifies the tool-call BATCHING path ([email protected] `stepId`): that live
+ * tool events AND replayed tool chunks carry `stepId`, and that the pure grouping
+ * selector folds a parallel batch into one group.
*/
// Provides globalThis.indexedDB + IDBKeyRange etc. for the idb adapter (a real
// browser has these natively; Bun does not). The product code is unchanged.
import "fake-indexeddb/auto";
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ConversationHistoryResponse,
+ 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, initialState, selectMessages } from "../src/core/chunks/index.ts";
+import {
+ applyHistory,
+ foldEvent,
+ groupRenderedChunks,
+ initialState,
+ selectChunks,
+ selectMessages,
+ type TranscriptState,
+} from "../src/core/chunks/index.ts";
+import {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ type MetricsState,
+ selectOrderedTurnMetrics,
+} from "../src/core/metrics/index.ts";
import { createConversationCache } from "../src/features/conversation-cache/index.ts";
const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203";
const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
-const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch";
-const conversationId = crypto.randomUUID();
+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.";
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 },
+): 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;
+}
+
+/** 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;
+}
+
+/** 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;
}
-async function historySync(id: string, sinceSeq: number): Promise<ConversationHistoryResponse> {
- const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`;
- 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;
+type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
+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;
+}
+
+/** Drive one turn to turn-sealed (or error), folding events into a fresh state. */
+async function runTurn(
+ socket: Socket,
+ conversationId: string,
+ prompt: string,
+): Promise<{
+ 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>();
+
+ 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 };
+}
+
+function toolChunksOf(state: TranscriptState) {
+ return selectChunks(state).filter(
+ (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
+ );
}
async function main() {
- console.log(`[live-probe] conversation=${conversationId} model=${MODEL}`);
- console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`);
-
- const cache = createConversationCache(createIdbChunkStore());
-
- let state = initialState();
- let gotCatalog = false;
- let deltaCount = 0;
- let sawTextDelta = false;
- let sawSeal = false;
- const done = Promise.withResolvers<void>();
-
- const onChat = (msg: ChatDeltaMessage | ChatErrorMessage) => {
- if (msg.type === "chat.error") {
- record("no chat.error", false, msg.message);
- done.resolve();
- return;
- }
- deltaCount++;
- if (msg.event.type === "text-delta") sawTextDelta = true;
- state = foldEvent(state, msg.event);
- if (msg.event.type === "turn-sealed") {
- sawSeal = true;
- done.resolve();
- }
- };
-
- 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,
- });
-
- // Give the socket a moment to open + deliver the catalog, then send the turn.
- await new Promise((r) => setTimeout(r, 500));
- record("WS connected + surface catalog received", gotCatalog);
-
- console.log(`\n[live-probe] sending chat.send: "${PROMPT}"`);
- socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL });
-
- // Wait for turn-sealed (or error), with a hard timeout.
- const timeout = setTimeout(() => done.resolve(), 90_000);
- await done.promise;
- clearTimeout(timeout);
-
- record("received chat.delta events", deltaCount > 0, `${deltaCount} deltas`);
- record("saw text-delta", sawTextDelta);
- record("turn reached turn-sealed", sawSeal);
-
- const provisionalText = selectMessages(state)
- .flatMap((m) => m.chunks)
- .filter((c) => c.type === "text")
- .map((c) => (c as { text: string }).text)
- .join("");
- console.log(`\n ↳ streamed assistant text (provisional): ${JSON.stringify(provisionalText)}`);
-
- // Post-seal: resync authoritative seq'd history + commit to cache (the real path).
- const sinceSeq = await cache.sinceSeq(conversationId);
- const hist = await historySync(conversationId, sinceSeq);
- record(
- "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("history chunks are seq-monotonic", monotonic);
-
- const merged = await cache.commit(conversationId, hist.chunks);
- state = applyHistory(state, merged);
- record(
- "provisional superseded after applyHistory (sealedTurnId cleared)",
- state.sealedTurnId === null,
- );
-
- const cached = await cache.load(conversationId);
- record(
- "IndexedDB cache persisted the turn",
- cached.length === hist.chunks.length,
- `${cached.length} cached`,
- );
-
- 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("");
- console.log(` ↳ committed assistant text (post-sync): ${JSON.stringify(committedText)}`);
- record("committed transcript has assistant text", committedText.length > 0);
-
- 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);
+ console.log(`[live-probe] model=${MODEL}`);
+ console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`);
+
+ 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);
+ },
+ });
+
+ 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);
+
+ 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,
+ );
+
+ // ─── 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",
+ );
+ }
+
+ // ─── 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");
+
+ 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.",
+ );
+ }
+
+ // 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}`,
+ );
+ }
+
+ 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);
}
main().catch((e) => fail(String(e)));
diff --git a/scripts/probe-cache-warming.ts b/scripts/probe-cache-warming.ts
new file mode 100644
index 0000000..1bf1f9b
--- /dev/null
+++ b/scripts/probe-cache-warming.ts
@@ -0,0 +1,277 @@
+/**
+ * scripts/probe-cache-warming.ts — LIVE probe of the `cache-warming` surface +
+ * conversation-close lifecycle against a RUNNING backend (bin/up: HTTP :24203 +
+ * surface WS :24205; override with PROBE_HTTP / PROBE_WS for bin/up2's +1000
+ * ports). NOT part of `bun run test`. Verifies the CR-4 handoff end-to-end:
+ *
+ * A. draft subscribe (no conversationId) → degenerate "no conversation" spec
+ * B. fresh conversation → warming defaults OFF, nothing scheduled (CR-4a)
+ * C. toggle on + 10s interval → repeated automatic warms, each update carrying
+ * a FUTURE nextWarmAt (CR-4b), initial `surface` echoes conversationId (CR-4d)
+ * D. POST /conversations/:id/close mid-turn → abortedTurn, done.reason
+ * "aborted", turn-sealed, warming disabled + unscheduled (CR-4c)
+ *
+ * bun scripts/probe-cache-warming.ts
+ */
+import type {
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ CloseConversationResponse,
+} from "@dispatch/transport-contract";
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
+import { parseControls } from "../src/features/cache-warming/logic/view-model.ts";
+
+const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
+const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203";
+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}` : ""}`);
+};
+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}`;
+}
+
+let catalog: { id: string; scope?: string }[] = [];
+let latestSpec: SurfaceSpec | null = null;
+let latestSpecConv: string | undefined;
+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);
+ },
+});
+
+/** 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);
+ };
+ });
+}
+
+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;
+}
+
+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 },
+ );
+}
+
+async function main() {
+ 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);
+
+ // ── 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(),
+ );
+
+ 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);
+
+ // ── 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 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),
+ );
+
+ // 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);
+}
+
+main().catch((e) => {
+ console.error(`[probe] FATAL: ${e}`);
+ process.exit(1);
+});
diff --git a/src/App.svelte b/src/App.svelte
index ffd5543..27c9271 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -1,7 +1,79 @@
<script lang="ts">
- import { App, createAppStore } from "./app";
+ 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";
- const store = createAppStore();
+ // 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)),
+ );
+
+ let route = $state<Route>(initialRoute);
+
+ // 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);
+ });
+
+ // 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);
+ }
</script>
-<App {store} />
+{#if route.kind === "home"}
+ <WorkspacesHome
+ store={workspaceStore}
+ onNavigate={navigate}
+ computers={store.computers}
+ hasActive={(id) => store.workspaceHasActiveConversations(id)}
+ />
+{:else}
+ <App {store} onNavigate={navigate} />
+{/if}
diff --git a/src/adapters/history/index.test.ts b/src/adapters/history/index.test.ts
new file mode 100644
index 0000000..faab9d4
--- /dev/null
+++ b/src/adapters/history/index.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { createHistoryAdapter, type HistoryWindow } from "./index";
+
+/**
+ * A minimal in-memory `HistoryWindow` fake for deterministic tests. `pathname`
+ * is a closure variable reflected through the `location` getter; `back(url)`
+ * simulates an external navigation (back/forward): it sets the path + fires the
+ * 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();
+ },
+ };
+}
+
+describe("createHistoryAdapter", () => {
+ 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("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("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([]);
+ });
+});
diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts
new file mode 100644
index 0000000..2886053
--- /dev/null
+++ b/src/adapters/history/index.ts
@@ -0,0 +1,93 @@
+/**
+ * History adapter — the injected browser effect for client-side routing.
+ *
+ * A thin wrapper over `window.history` + the `popstate` event that exposes the
+ * current pathname, a `navigate` (pushState) that updates the URL without a
+ * reload, and a `subscribe` for path changes (back/forward buttons + programmatic
+ * navigation). The route SEMANTICS (path → workspace/home) live in the
+ * `workspaces` feature's pure `parsePath`; this adapter deals only in path
+ * strings, so it stays generic + reusable.
+ *
+ * The browser edge (`window`/`history`) is INJECTED (defaults to the global) so
+ * it is testable without the DOM and degrades to a no-op when absent (SSR / no
+ * `window`).
+ */
+
+/** 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;
+}
+
+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;
+}
+
+export interface CreateHistoryOptions {
+ /** 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;
+ },
+ };
+}
+
+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 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);
+ };
+ },
+ };
+}
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/portal.test.ts b/src/adapters/portal.test.ts
new file mode 100644
index 0000000..a5624d5
--- /dev/null
+++ b/src/adapters/portal.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { portal } from "./portal";
+
+describe("portal action", () => {
+ afterEach(() => {
+ // Strip any leftover teleported nodes between tests.
+ document.querySelectorAll("body > :not(script)").forEach((n) => {
+ if (n instanceof HTMLElement) n.remove();
+ });
+ });
+
+ it("teleports the node to document.body (escaping an ancestor with transform)", () => {
+ // Simulate the sidebar: a transformed ancestor establishes a containing
+ // block for `position: fixed`.
+ const ancestor = document.createElement("div");
+ ancestor.style.transform = "translateX(0)";
+ document.body.appendChild(ancestor);
+
+ const node = document.createElement("div");
+ node.setAttribute("data-testid", "modal");
+ ancestor.appendChild(node);
+ expect(node.parentNode).toBe(ancestor);
+
+ const action = portal(node);
+
+ // After the action, the node is a direct child of <body>, not the ancestor.
+ expect(node.parentNode).toBe(document.body);
+ expect(ancestor.contains(node)).toBe(false);
+
+ action.destroy();
+
+ // On destroy the node is removed from <body>.
+ expect(document.body.contains(node)).toBe(false);
+ });
+
+ it("is a no-op (does not throw) when document is unavailable (SSR guard)", () => {
+ const originalDocument = globalThis.document;
+ // @ts-expect-error — deliberately undefined to exercise the SSR guard.
+ globalThis.document = undefined;
+ try {
+ const stub = {} as HTMLElement;
+ const action = portal(stub);
+ // Must not throw, and returns a destroy that is safe to call.
+ action.destroy();
+ } finally {
+ globalThis.document = originalDocument;
+ }
+ });
+});
diff --git a/src/adapters/portal.ts b/src/adapters/portal.ts
new file mode 100644
index 0000000..afe42e7
--- /dev/null
+++ b/src/adapters/portal.ts
@@ -0,0 +1,28 @@
+/**
+ * A Svelte `use:` action that teleports a node to `document.body`, escaping any
+ * ancestor that establishes a containing block for `position: fixed` (most
+ * commonly an ancestor with a `transform`, `filter`, `perspective`, or
+ * `will-change` — e.g. the sidebar's `transform: translateX(...)` container).
+ *
+ * Without this, a `position: fixed` modal rendered inside such an ancestor is
+ * positioned relative to the ANCESTOR, not the viewport (so it only covers the
+ * sidebar area instead of the full screen). Moving the node to `document.body`
+ * restores viewport-relative `fixed` positioning. Svelte still owns the node's
+ * lifecycle (children, bindings, events); we just relocate it + remove it on
+ * destroy as hygiene.
+ *
+ * No-op safely when there is no `document` (SSR / jsdom guards).
+ */
+export function portal(node: HTMLElement): { destroy(): void } {
+ if (typeof document === "undefined") {
+ return { destroy() {} };
+ }
+ document.body.appendChild(node);
+ return {
+ destroy() {
+ if (node.parentNode === document.body) {
+ document.body.removeChild(node);
+ }
+ },
+ };
+}
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts
index 961f919..9d821e2 100644
--- a/src/adapters/ws/index.test.ts
+++ b/src/adapters/ws/index.test.ts
@@ -3,344 +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("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 54a501c..1309db0 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -1,108 +1,123 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- 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;
- 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 {
- 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 546afe1..dd2b773 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -2,253 +2,399 @@ 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("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 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("accepts the `queued` status (CR-13 — waiting for a concurrency slot)", () => {
+ const data = JSON.stringify({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "queued",
+ workspaceId: "w1",
+ });
+ expect(parseServerMessage(data)).toEqual({
+ type: "conversation.statusChanged",
+ conversationId: "c1",
+ status: "queued",
+ 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 6592f1b..03ef763 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -1,32 +1,38 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- 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",
+ "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);
}
/**
@@ -34,74 +40,126 @@ 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;
- return { type: "surface", spec: spec as unknown as SurfaceMessage["spec"] };
- }
- 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;
- }
- 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 !== "queued" &&
+ 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;
+ }
}
/**
@@ -109,10 +167,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 37131d3..f5b269c 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,8 +1,129 @@
@import "tailwindcss";
+/* Syntax-highlight theme for fenced code blocks in rendered Markdown. */
+@import "highlight.js/styles/atom-one-dark.min.css";
-/* DaisyUI v5 — enable the plugin AND bundle the dracula theme (set as default,
- applied via <html data-theme="dracula">). Themes not listed here are NOT
- bundled, so dracula must be named explicitly, not merely referenced. */
+/* Custom monokai theme — must be imported BEFORE the @plugin block. */
+@import "./themes/monokai.css";
+
+/* DaisyUI v5 — enable the plugin AND bundle the monokai theme (set as default,
+ 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: dracula --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;
+ }
+}
+
+/* App shell fills the viewport and never scrolls/overflows at the page level —
+ the inner regions (tab strip, chat transcript) own their own scrolling. */
+html,
+body,
+#app {
+ height: 100%;
+}
+
+body {
+ overflow: hidden;
}
diff --git a/src/app/App.svelte b/src/app/App.svelte
index cc9866e..7e5ac7d 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,94 +1,847 @@
<script lang="ts">
+ import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract";
import type { InvokeMessage } from "@dispatch/ui-contract";
- import { ChatView, Composer, ModelSelector } from "../features/chat";
- import { TabBar } from "../features/tabs";
- import { SurfaceView } from "../features/surface-host";
+ 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 ComposerStatus,
+ 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, TabList } 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 {
+ HeartbeatView,
+ manifest as heartbeatManifest,
+ RunModal,
+ type HeartbeatConfigResult,
+ type HeartbeatNextRunResult,
+ type HeartbeatRunView,
+ type HeartbeatRunsResult,
+ type HeartbeatStopResult,
+ } from "../features/heartbeat";
+ import {
+ ConcurrencyView,
+ manifest as concurrencyManifest,
+ type DeleteConcurrencyLimit,
+ type LoadConcurrencyLimits,
+ type LoadConcurrencyStatus,
+ type SaveConcurrencyCooldown,
+ type SaveConcurrencyLimit,
+ } from "../features/concurrency";
+ import type { ChatStore } from "../features/chat";
+ import {
+ SystemPromptBuilder,
+ type LoadSystemPrompt as LoadSystemPromptAlias,
+ type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
+ type SaveSystemPrompt as SaveSystemPromptAlias,
+ manifest as systemPromptManifest,
+ } from "../features/system-prompt";
+ import {
+ VisionSettingsView,
+ manifest as visionManifest,
+ type LoadVisionSettingsResult,
+ type SaveVisionSettingsResult,
+ type VisionSettingsPatch,
+ } from "../features/vision";
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();
+ let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
- function handleSelect(surfaceId: string) {
- store.select(surfaceId);
+ // 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: "tabs", label: "Tabs" },
+ { 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: "heartbeat", label: "Heartbeat" },
+ { id: "concurrency", label: "Concurrency" },
+ { id: "system-prompt", label: "System Prompt" },
+ { id: "settings", label: "Settings" },
+ ] as const;
+
+ // Default sidebar layout: the Tabs list (the moved-from-top tab bar) plus the
+ // Model view, so a fresh user sees their conversations + model controls.
+ const DEFAULT_VIEWS: readonly string[] = ["tabs", "model"];
+ const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
+ storage: untrack(() => store.storage),
+ });
+ 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,
+ heartbeatManifest,
+ concurrencyManifest,
+ visionManifest,
+ ].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);
+ });
+
+ // Top-bar title: the active tab's title, or "New Tab" when no tab is active
+ // (a fresh, unstarted draft — the conversation hasn't been sent yet, so no
+ // tab exists). Pure-derived from the (workspace-filtered) tab set + the
+ // active id; reflects whichever tab is selected in the sidebar's Tabs view.
+ const NEW_TAB_TITLE = "New Tab";
+ const topBarTitle = $derived.by(() => {
+ const id = store.activeConversationId;
+ if (id === null) return NEW_TAB_TITLE;
+ const tab = store.tabs.find((t) => t.conversationId === id);
+ return tab?.title ?? NEW_TAB_TITLE;
+ });
+
+ // The composer status-bar status. Priority: error > queued > running > idle.
+ // `queued` (the turn is in flight but waiting for a concurrency slot — CR-13)
+ // wins over `running` so the corner shows a ring, not dots, during the wait.
+ // `turn-start` fires before the slot is granted, so `generating` is already
+ // true while `conversationStatus === "queued"`; the explicit queued check is
+ // what distinguishes the two.
+ const composerStatus = $derived.by<ComposerStatus>(() => {
+ if (store.activeChat.error) return "error";
+ const id = store.activeConversationId;
+ if (id !== null && store.conversationStatus(id) === "queued") return "queued";
+ return store.activeChat.generating ? "running" : "idle";
+ });
+
+ // 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);
+ // The heartbeat run currently open in the fullscreen run-chat modal (null =
+ // closed). Holds a snapshot run view; the modal re-mounts per run (keyed).
+ let heartbeatRun = $state<HeartbeatRunView | null>(null);
+
+ $effect(() => {
+ sidebarOpenStore.save(sidebarOpen);
+ });
+
function handleInvoke(msg: InvokeMessage) {
store.invoke(msg.surfaceId, msg.actionId, msg.payload);
}
- function handleSend(text: string) {
- store.send(text);
+ function handleSend(text: string, images?: readonly ImageInput[]): void {
+ store.send(text, images);
+ }
+
+ function handleQueue(text: string) {
+ store.queueMessage(text);
+ }
+
+ function handleStop() {
+ store.stopGeneration();
}
function handleSelectModel(model: string) {
store.selectModel(model);
}
-</script>
-<main class="flex h-screen flex-col">
- <div class="flex items-center justify-between border-b border-base-300 px-4 py-2">
- <h1 class="text-lg font-bold">Dispatch</h1>
- </div>
+ // 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 };
+ }
- {#if store.lastError}
- <div role="alert" class="alert alert-error mx-4 mt-2">
- <strong>Error:</strong>
- {store.lastError.message}
- </div>
- {/if}
+ // 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 };
+ }
- {#if store.activeChat.error}
- <div role="alert" class="alert alert-warning mx-4 mt-2">
- <strong>Chat error:</strong>
- {store.activeChat.error}
- </div>
- {/if}
+ // 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 };
+ }
- <TabBar
- tabs={store.tabs}
- activeConversationId={store.activeConversationId}
- onSelect={(id) => store.selectTab(id)}
- onClose={(id) => store.closeTab(id)}
- onNewDraft={() => store.newDraft()}
- />
+ 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 };
+ }
- <div class="flex flex-1 flex-col overflow-hidden">
- <div class="flex items-center gap-2 px-4 py-2">
- <ModelSelector
- models={store.models}
- selected={store.activeModel}
- onSelect={handleSelectModel}
- />
+ // Adapt the store's global vision-settings API to the vision feature's ports.
+ async function loadVisionSettings(): Promise<LoadVisionSettingsResult> {
+ // The store seeds `visionSettings` on boot; a refresh keeps it current.
+ await store.refreshVisionSettings();
+ const settings = store.visionSettings;
+ if (settings === null) {
+ return { ok: false, error: "Vision settings not available." };
+ }
+ return { ok: true, settings };
+ }
+
+ async function saveVisionSettings(
+ patch: VisionSettingsPatch,
+ ): Promise<SaveVisionSettingsResult> {
+ const result = await store.setVisionSettings(patch);
+ if (result === null) return { ok: false, error: "Vision settings not available." };
+ return result.ok
+ ? { ok: true, settings: result.settings }
+ : { 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);
+
+ // Adapt the store's heartbeat results to the heartbeat feature's ports. The
+ // store returns the feature's result types directly (the API is a plain REST
+ // surface, not a transport-contract type), so the adapter is a thin passthrough
+ // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md
+ // "contracts are the cross-unit surface").
+ async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> {
+ return store.heartbeatConfig();
+ }
+
+ async function saveHeartbeatConfig(
+ patch: Parameters<typeof store.setHeartbeatConfig>[0],
+ ): Promise<HeartbeatConfigResult> {
+ return store.setHeartbeatConfig(patch);
+ }
+
+ async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> {
+ return store.heartbeatRuns();
+ }
+
+ async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ return store.stopHeartbeatRun(runId);
+ }
+
+ async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ return store.heartbeatNextRun();
+ }
+
+ // Run-chat modal: open a live watch on the run's conversation (the store owns
+ // the ChatStore + the `chat.subscribe` stream), and tear it down on close.
+ function openRunChat(conversationId: string): ChatStore {
+ return store.watchConversation(conversationId);
+ }
+ function closeRunChat(conversationId: string): void {
+ store.unwatchConversation(conversationId);
+ }
+
+ // Adapt the store's concurrency results to the feature's ports. The store
+ // returns the feature's result types directly (the API is a plain REST surface
+ // under /concurrency, not a workspace/conversation-scoped one), so the adapter
+ // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts
+ // are the cross-unit surface").
+ const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
+ const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
+ store.setConcurrencyLimit(providerId, limit);
+ const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
+ store.deleteConcurrencyLimit(providerId);
+ const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
+ const saveConcurrencyCooldown: SaveConcurrencyCooldown = (providerId, cooldownMs) =>
+ store.setConcurrencyCooldown(providerId, cooldownMs);
+</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. -->
+ <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
+ <!-- Slim header: the tab bar moved into the sidebar (the "Tabs" view), so
+ the top row now shows the active tab's title on the left (or "New Tab"
+ for an unstarted draft), with the build version + sidebar toggle on
+ the right. -->
+ <div class="flex items-center justify-between gap-2 px-2 py-2">
+ <span
+ class="min-w-0 flex-1 shrink truncate pl-2 text-sm font-medium opacity-70"
+ data-testid="top-bar-title"
+ title={topBarTitle}
+ aria-label="Active conversation title"
+ >
+ {topBarTitle}
+ </span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2"
+ aria-label="Back to dashboard"
+ title="Back to dashboard"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/");
+ }}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V15a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v5.25a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75V9.75M8.25 21h8.25"
+ />
+ </svg>
+ </a>
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
+ title="Build version (git short hash)"
+ >
+ build: {__APP_VERSION__}
+ </span>
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
+ aria-label={sidebarOpen ? "Close sidebar" : "Open 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"
+ >
+ {#if sidebarOpen}
+ <!-- Sidebar open → chevrons point right -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m11.25 4.5 7.5 7.5-7.5 7.5m-7.5-15 7.5 7.5-7.5 7.5"
+ />
+ {:else}
+ <!-- Sidebar closed → chevrons point left -->
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M18.75 19.5l-7.5-7.5 7.5-7.5m-6 15L5.25 12l7.5-7.5"
+ />
+ {/if}
+ </svg>
+ </button>
</div>
- <div class="flex-1 overflow-y-auto">
- <ChatView chunks={store.activeChat.chunks} />
+ {#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 pr-4">
+ <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}
+ apiBaseUrl={store.httpBase}
+ />
+ {/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>
- <Composer onSend={handleSend} />
+ {#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={composerStatus}
+ />
</div>
- {#if store.catalog.length > 0}
- <section class="border-t border-base-300 p-4">
- <h2 class="mb-2 text-sm font-semibold">Surfaces</h2>
- <div class="flex flex-wrap gap-2">
- {#each store.catalog as entry (entry.id)}
- <button
- class="btn btn-sm"
- class:btn-active={entry.id === store.selectedId}
- aria-current={entry.id === store.selectedId ? "true" : undefined}
- onclick={() => handleSelect(entry.id)}
- >
- {entry.title}
- <span class="text-xs opacity-60">({entry.region})</span>
- </button>
- {/each}
- </div>
- </section>
+ <!-- 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 bg-base-100 pt-3 pr-3 pb-3 transition-transform duration-300 ease-out"
+ style="transform: translateX({sidebarOpen ? '0' : '100%'})"
+ >
+ <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
+ </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()} />
+{/if}
- {#if store.selectedSpec}
- <section class="border-t border-base-300 p-4">
- <SurfaceView spec={store.selectedSpec} onInvoke={handleInvoke} />
+{#if systemPromptModalOpen}
+ <SystemPromptBuilder
+ loadPrompt={loadSystemPromptPrompt}
+ savePrompt={saveSystemPromptPrompt}
+ loadVariables={loadSystemPromptVariablesPrompt}
+ onClose={() => (systemPromptModalOpen = false)}
+ />
+{/if}
+
+{#if heartbeatRun !== null}
+ <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a
+ fresh watch store lifecycle per run. The modal owns the live watch
+ (openChat/closeChat) and the Stop button. -->
+ {#key heartbeatRun.id}
+ <RunModal
+ run={heartbeatRun}
+ openChat={openRunChat}
+ closeChat={closeRunChat}
+ stopRun={stopHeartbeatRun}
+ onClose={() => (heartbeatRun = null)}
+ apiBaseUrl={store.httpBase}
+ />
+ {/key}
+{/if}
+
+{#snippet viewContent(kind: string)}
+ {#if kind === "tabs"}
+ <!-- The conversation tab list (moved out of the top bar into the sidebar).
+ Re-mount per workspace so the filtered tab set + scroll reset cleanly
+ on a workspace switch. -->
+ {#key store.activeWorkspaceId}
+ <TabList
+ tabs={store.tabs}
+ activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
+ onSelect={(id) => store.selectTab(id)}
+ onClose={(id) => store.closeTab(id)}
+ onNewDraft={() => store.newDraft()}
+ onRename={(id, title) => store.renameTab(id, title)}
+ />
+ {/key}
+ {:else if kind === "model"}
+ <div class="flex flex-col gap-3">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ modelInfo={store.modelInfo}
+ />
+ <!-- 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.
+ TodoList always reserves its fixed 60vh height (empty or full). -->
+ {#key store.activeConversationId}
+ <TodoList payload={todoData} />
+ {/key}
+ {:else if kind === "compaction"}
+ <!-- Message compaction is per-conversation (keyed so the percent + feedback
+ can't bleed across tabs). Vision (image-compaction) settings are GLOBAL,
+ so they stay mounted across conversation switches (no {#key}). -->
+ {#key store.currentConversationId}
+ <CompactionView
+ percent={store.compactPercent}
+ canCompact={store.activeConversationId !== null}
+ {compactNow}
+ savePercent={saveCompactPercent}
+ />
+ {/key}
+ <div class="divider my-1 text-xs text-base-content/40">Image compaction</div>
+ <VisionSettingsView
+ models={store.models}
+ modelInfo={store.modelInfo}
+ load={loadVisionSettings}
+ save={saveVisionSettings}
+ />
+ {: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>
+ {:else if kind === "heartbeat"}
+ <!-- Workspace-scoped autonomous-agent heartbeat (config + run history).
+ Not conversation-scoped (no {#key}); the config + runs are per-workspace. -->
+ <HeartbeatView
+ models={store.models}
+ loadConfig={loadHeartbeatConfig}
+ saveConfig={saveHeartbeatConfig}
+ loadRuns={loadHeartbeatRuns}
+ stopRun={stopHeartbeatRun}
+ loadVariables={loadSystemPromptVariablesPrompt}
+ loadDefaultPrompt={loadSystemPromptPrompt}
+ loadNextRun={loadHeartbeatNextRun}
+ onOpenRun={(run) => (heartbeatRun = run)}
+ />
+ {:else if kind === "concurrency"}
+ <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or
+ conversation-scoped), so the panel stays mounted across tab switches. -->
+ <ConcurrencyView
+ models={store.models}
+ loadLimits={loadConcurrencyLimits}
+ saveLimit={saveConcurrencyLimit}
+ deleteLimit={deleteConcurrencyLimit}
+ loadStatus={loadConcurrencyStatus}
+ saveCooldown={saveConcurrencyCooldown}
+ />
{/if}
-</main>
+{/snippet}
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index 8110d41..dcdcb9b 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -1,406 +1,586 @@
-import type { WsServerMessage } from "@dispatch/transport-contract";
+import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { render, screen, within } from "@testing-library/svelte";
+import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
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,
- });
- }
- 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;
}
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("renders catalog buttons when surfaces are available", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- 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 } });
-
- const surfacesSection = screen.getByRole("heading", { name: "Surfaces" }).closest("section");
- if (surfacesSection === null) throw new Error("Surfaces section not found");
- const buttons = within(surfacesSection).getAllByRole("button");
- expect(buttons).toHaveLength(2);
- expect(buttons[0]).toHaveTextContent("Surface One");
- expect(buttons[1]).toHaveTextContent("Surface Two");
-
- store.dispose();
- });
-
- it("clicking a catalog entry subscribes and renders its surface", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
- });
-
- render(App, { props: { store } });
-
- const user = userEvent.setup();
- const button = screen.getByRole("button", { name: /Surface One/ });
- ws.sent.length = 0;
- await user.click(button);
-
- const msgs = sentMessages(ws);
- const subscribe = msgs.find(
- (m: { type: string; surfaceId: string }) => m.type === "subscribe" && m.surfaceId === "s1",
- );
- expect(subscribe).toBeTruthy();
-
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
- },
- });
-
- expect(await screen.findByRole("heading", { name: "Surface One" })).toBeInTheDocument();
- expect(await screen.findByText("Tokens")).toBeInTheDocument();
- expect(await screen.findByText("1,234")).toBeInTheDocument();
-
- store.dispose();
- });
-
- it("clicking a different entry unsubscribes the previous then subscribes the new", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- 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 } });
-
- const user = userEvent.setup();
- await user.click(screen.getByRole("button", { name: /Surface One/ }));
- ws.sent.length = 0;
-
- await user.click(screen.getByRole("button", { name: /Surface Two/ }));
-
- const msgs = sentMessages(ws) as Array<{ type: string; surfaceId: string }>;
- const unsubIdx = msgs.findIndex((m) => m.type === "unsubscribe" && m.surfaceId === "s1");
- const subIdx = msgs.findIndex((m) => m.type === "subscribe" && m.surfaceId === "s2");
- expect(unsubIdx).toBeGreaterThanOrEqual(0);
- expect(subIdx).toBeGreaterThanOrEqual(0);
- expect(unsubIdx).toBeLessThan(subIdx);
-
- store.dispose();
- });
-
- it("selected catalog button reflects aria-current", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- 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 } });
-
- const user = userEvent.setup();
- const btn1 = screen.getByRole("button", { name: /Surface One/ });
- const btn2 = screen.getByRole("button", { name: /Surface Two/ });
-
- await user.click(btn1);
- expect(btn1).toHaveAttribute("aria-current", "true");
- expect(btn2).not.toHaveAttribute("aria-current");
-
- await user.click(btn2);
- expect(btn2).toHaveAttribute("aria-current", "true");
- expect(btn1).not.toHaveAttribute("aria-current");
-
- 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: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
- });
-
- render(App, { props: { store } });
-
- const user = userEvent.setup();
- await user.click(screen.getByRole("button", { name: /Surface One/ }));
-
- 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 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, onNavigate: vi.fn() } });
+
+ 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("shows 'New Tab' in the top bar for an unstarted draft (no active tab)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ expect(store.activeConversationId).toBeNull();
+ expect(screen.getByTestId("top-bar-title")).toHaveTextContent("New Tab");
+
+ store.dispose();
+ });
+
+ it("shows the active tab's title in the top bar once a tab is started", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ // Promote draft → tab: the tab's title is derived from the first message.
+ store.send("Refactor the auth module");
+ expect(store.activeConversationId).not.toBeNull();
+
+ // The top-bar title updates reactively; findByTestId awaits the flush.
+ expect(await screen.findByTestId("top-bar-title")).toHaveTextContent(
+ "Refactor the auth module",
+ );
+
+ store.dispose();
+ });
+
+ it("the dashboard home button navigates to '/' (back to the workspaces home)", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const onNavigate = vi.fn();
+ render(App, { props: { store, onNavigate } });
+
+ const homeBtn = screen.getByRole("link", { name: "Back to dashboard" });
+ expect(homeBtn).toHaveAttribute("href", "/");
+ await userEvent.setup().click(homeBtn);
+
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith("/");
+
+ store.dispose();
+ });
+
+ it("auto-subscribes to every catalog entry on render (no buttons to click)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ 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, onNavigate: vi.fn() } });
+
+ 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, onNavigate: vi.fn() } });
+
+ // 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, onNavigate: vi.fn() } });
+
+ 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, onNavigate: vi.fn() } });
+
+ 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, onNavigate: vi.fn() } });
+
+ 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, onNavigate: vi.fn() } });
+
+ 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, onNavigate: vi.fn() } });
+
+ // 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, onNavigate: vi.fn() } });
+
+ // Extensions view is pre-populated in the fake storage, so the modules table renders immediately.
+ expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument();
+ 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, onNavigate: vi.fn() } });
+
+ // 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, onNavigate: vi.fn() } });
+
+ // 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
new file mode 100644
index 0000000..0415827
--- /dev/null
+++ b/src/app/ErrorModal.svelte
@@ -0,0 +1,150 @@
+<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();
+
+ 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);
+ }
+ }
+ }
+
+ function handleKeydown(event: KeyboardEvent): void {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onDismiss();
+ }
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+<!-- 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"
+>
+ <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>
+
+ <!-- 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 760c390..22b0a25 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1,364 +1,2160 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ConversationHistoryResponse,
- ModelsResponse,
+ 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,
+ SetVisionSettingsRequest,
+ SystemPromptTemplateResponse,
+ SystemPromptVariable,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ WarmRequest,
+ WarmResponse,
} from "@dispatch/transport-contract";
-import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
+import { untrack } from "svelte";
import { createIdbChunkStore } from "../adapters/idb";
import { createLocalStore } from "../adapters/local-storage";
import type { WebSocketLike } from "../adapters/ws";
import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws";
+import { normalizeChatLimit } from "../core/chunks";
import {
- applyServerMessage,
- 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 } from "../features/chat";
+import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import {
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+} from "../features/concurrency";
import type { ConversationCache } from "../features/conversation-cache";
import { createConversationCache } from "../features/conversation-cache";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
+import {
+ normalizeVisionSettings,
+ type VisionSettings,
+ type VisionSettingsPatch,
+} from "../features/vision";
import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
import { randomId } from "./uuid";
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 };
+
+/** Outcome of `PUT /conversations/:id/cwd`. */
+export type CwdResult =
+ | { 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 };
+
+/** 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 };
+
+/** 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 };
+
+/** Outcome of `GET /conversations/:id/lsp`. */
+export type LspResult =
+ | { 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 };
+
+/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
+export type ReasoningEffortResult =
+ | { 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 };
+
+/** Outcome of `PUT /conversations/:id/compact-percent`. */
+export type CompactPercentResult =
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `PUT /settings/vision` (global vision-settings save). */
+export type VisionSettingsResult =
+ | { readonly ok: true; readonly settings: VisionSettings }
+ | { 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 };
+
+/** 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 };
+
+/** 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 };
+
export interface AppStore {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
- readonly activeChat: ChatStore;
- readonly models: readonly string[];
- readonly activeModel: string;
- readonly catalog: ProtocolState["catalog"];
- readonly selectedId: string | null;
- readonly selectedSpec: SurfaceSpec | null;
- readonly lastError: ProtocolState["lastError"];
- send(text: string): void;
- selectModel(model: string): void;
- newDraft(): void;
- selectTab(conversationId: string): void;
- closeTab(conversationId: string): void;
- select(surfaceId: string): void;
- invoke(surfaceId: string, actionId: string, payload?: unknown): 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;
+ /**
+ * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to
+ * resolve relative image URLs served by the backend (`/images/…`) into
+ * absolute URLs for `<img src>`.
+ */
+ readonly httpBase: 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 a user message (start a turn). Forwards any staged `images`
+ * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op;
+ * the server passes them to a vision-capable model natively or transcribes
+ * them via vision handoff for a non-vision model. Omitted on the wire when
+ * none are staged. On a draft, promotes to a tab first.
+ */
+ send(text: string, images?: readonly ImageInput[]): 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>;
+ /**
+ * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max
+ * native images per turn before compaction; 0 = disabled) + `compactionModel`
+ * (which vision model transcribes old images; null = auto). Shared across all
+ * conversations. Seeded on boot; `null` = not yet fetched.
+ */
+ readonly visionSettings: VisionSettings | null;
+ /**
+ * Refetch the global vision settings (`GET /settings/vision`). Called by the
+ * vision-settings view on mount; also seeded on boot.
+ */
+ refreshVisionSettings(): Promise<void>;
+ /**
+ * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either
+ * field may be omitted. Returns the merged settings on success.
+ */
+ setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | 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;
+ /**
+ * Whether at least one conversation in the given workspace is currently
+ * active or queued (generating / waiting for a concurrency slot) — drives
+ * the loading-dots indicator on workspace cards. Backed by a once-derived
+ * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses)
+ * so this is an O(1) lookup, not a per-card scan of the full tab list.
+ * Reactive: the set is a `$derived`, so a Svelte template expression calling
+ * this re-runs when the tab set or status map changes.
+ */
+ workspaceHasActiveConversations(workspaceId: string): boolean;
+ /**
+ * 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;
+ /**
+ * Load the active workspace's heartbeat config
+ * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
+ * the backend runs an autonomous agent loop on a configured interval, writing
+ * each run into a dedicated conversation. The config covers the system/task
+ * prompts, model, reasoning effort, interval, and an enabled flag.
+ */
+ heartbeatConfig(): Promise<HeartbeatConfigResult>;
+ /**
+ * Persist a partial heartbeat config patch
+ * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
+ * stored config; returns the full updated config.
+ */
+ setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
+ /**
+ * Load the active workspace's heartbeat run history
+ * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
+ * it wrote to — open one via {@link watchConversation} to see its chat live.
+ */
+ heartbeatRuns(): Promise<HeartbeatRunsResult>;
+ /**
+ * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ * The run's in-flight turn seals (its conversation keeps streaming until it
+ * ends); the run's status flips to `stopped` (visible on the next runs poll).
+ */
+ stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
+ /**
+ * Fetch the server-authoritative next-run timestamp
+ * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
+ * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
+ * a live countdown from this. When the endpoint is absent (404 — backend
+ * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
+ * an approximation from the runs + config.
+ */
+ heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal): ensures a live {@link ChatStore} for the conversation, subscribing
+ * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
+ * tab's store if the conversation is already a tab; otherwise creates an
+ * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
+ * routed to it automatically. Pair every open with {@link unwatchConversation}
+ * on close to unsubscribe + dispose the ephemeral store.
+ */
+ watchConversation(conversationId: string): ChatStore;
+ /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
+ unwatchConversation(conversationId: string): void;
+ /**
+ * Load all configured per-provider concurrency limits
+ * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
+ * list when the concurrency extension isn't loaded (`{ limits: [] }`).
+ */
+ concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
+ /**
+ * Fetch the configured limit for one provider
+ * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
+ * `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
+ /**
+ * Set or update a provider's concurrency limit
+ * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
+ * positive integer (a non-positive body is `400`). At the cap, further requests
+ * queue oldest-agent-first rather than being sent immediately.
+ */
+ setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
+ /**
+ * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
+ * making it unlimited. `404` (not configured) and `503` (extension not loaded)
+ * both surface as `ok: false`.
+ */
+ deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
+ /**
+ * Fetch live concurrency status for every provider with a configured limit
+ * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each
+ * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag
+ * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns
+ * an empty list when the extension isn't loaded (`{ providers: [] }`).
+ */
+ concurrencyStatus(): Promise<ConcurrencyStatusResult>;
+ /**
+ * Fetch the per-slot release cooldown (ms) for one provider
+ * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at
+ * all) and `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult>;
+ /**
+ * Set the per-slot release cooldown (ms) for one provider
+ * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs`
+ * must be a non-negative integer (0 = no cooldown / instant re-admission); an
+ * invalid body is `400`. Persists + applies to subsequently recycled slots.
+ */
+ setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult>;
+ /**
+ * 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;
+ 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;
+ };
}
-function createHistorySync(
- httpBase: string,
- fetchImpl: typeof fetch,
-): (conversationId: string, sinceSeq: number) => Promise<ConversationHistoryResponse> {
- return async (conversationId: string, sinceSeq: number) => {
- const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`;
- 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;
+ };
}
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
- let protocol = $state<ProtocolState>(protocolInitialState());
- let selectedId = $state<string | null>(null);
- let models = $state<readonly string[]>([]);
- let activeModel = $state(DEFAULT_MODEL);
-
- 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);
-
- const cache: ConversationCache = createConversationCache(
- createIdbChunkStore({ indexedDB: indexedDBFactory }),
- );
-
- const historySync = createHistorySync(httpBase, fetchImpl);
-
- const chatStores = new Map<string, ChatStore>();
-
- function createChatFor(conversationId: string, model: string): ChatStore {
- return createChatStore({
- conversationId,
- model,
- transport: {
- send(msg) {
- socket?.send(msg);
- },
- },
- historySync,
- cache,
- });
- }
-
- const initialDraftId = randomId();
- let draftStore: ChatStore = createChatFor(initialDraftId, activeModel);
- let draftConversationId: string = initialDraftId;
-
- let activeChat = $state<ChatStore>(draftStore as ChatStore);
-
- 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);
- }
- }
-
- function handleServerMessage(msg: SurfaceServerMessage): void {
- protocol = applyServerMessage(protocol, msg);
- }
-
- let socket: ReturnType<typeof createSurfaceSocket> | null = null;
-
- const socketOpts: SurfaceSocketOptions = {
- url: wsUrl,
- onMessage: handleServerMessage,
- onChat: handleChatMessage,
- onReopen() {
- if (selectedId !== null) {
- const result = protocolSubscribe(protocol, selectedId);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- }
- },
- };
- 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;
- if (data.models.length > 0 && !data.models.includes(activeModel)) {
- const first = data.models[0];
- if (first !== undefined) {
- activeModel = first;
- }
- }
- })
- .catch(() => {
- // Model fetch failure is non-fatal; use defaults.
- });
-
- // 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);
- chatStores.set(tab.conversationId, store);
- void store.load();
- }
- if (persistedState.activeConversationId !== null) {
- const activeTab = persistedState.tabs.find(
- (t) => t.conversationId === persistedState.activeConversationId,
- );
- if (activeTab !== undefined) {
- activeModel = activeTab.model;
- }
- }
- }
-
- refreshActiveChat();
-
- return {
- get tabs(): readonly Tab[] {
- return tabsStore.tabs;
- },
- get activeConversationId(): string | null {
- return tabsStore.activeConversationId;
- },
- get activeChat(): ChatStore {
- return activeChat;
- },
- get models(): readonly string[] {
- return models;
- },
- get activeModel(): string {
- return activeModel;
- },
- get catalog() {
- return protocol.catalog;
- },
- get selectedId() {
- return selectedId;
- },
- get selectedSpec() {
- if (selectedId === null) return null;
- return protocol.subscriptions.get(selectedId) ?? null;
- },
- get lastError() {
- return protocol.lastError;
- },
-
- 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),
- });
- chatStores.set(conversationId, draftStore);
- void draftStore.load();
-
- // Prepare next draft
- const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel);
- draftConversationId = nextDraftId;
-
- refreshActiveChat();
- // Now send on the promoted store
- chatStores.get(conversationId)?.send(text);
- } else {
- activeChat.send(text);
- }
- },
-
- selectModel(model: string): void {
- activeModel = model;
- const activeId = tabsStore.activeConversationId;
- if (activeId !== null) {
- tabsStore.setModel(activeId, model);
- chatStores.get(activeId)?.setModel(model);
- } else {
- draftStore.setModel(model);
- }
- },
-
- newDraft(): void {
- tabsStore.newDraft();
- const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel);
- draftConversationId = nextDraftId;
- refreshActiveChat();
- },
-
- selectTab(conversationId: string): void {
- tabsStore.selectTab(conversationId);
- const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId);
- if (tab !== undefined) {
- activeModel = tab.model;
- }
- refreshActiveChat();
- },
-
- closeTab(conversationId: string): void {
- tabsStore.closeTab(conversationId);
- const store = chatStores.get(conversationId);
- if (store !== undefined) {
- store.dispose();
- chatStores.delete(conversationId);
- }
- void cache.delete(conversationId);
- refreshActiveChat();
- },
-
- select(surfaceId: string): void {
- if (selectedId !== null && selectedId !== surfaceId) {
- const unsub = protocolUnsubscribe(protocol, selectedId);
- protocol = unsub.state;
- for (const msg of unsub.outgoing) {
- socket?.send(msg);
- }
- }
- selectedId = surfaceId;
- const sub = protocolSubscribe(protocol, surfaceId);
- protocol = sub.state;
- for (const msg of sub.outgoing) {
- socket?.send(msg);
- }
- },
- invoke(surfaceId: string, actionId: string, payload?: unknown): void {
- const result = protocolInvoke(protocol, surfaceId, actionId, payload);
- protocol = result.state;
- for (const msg of result.outgoing) {
- socket?.send(msg);
- }
- },
- 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>();
+
+ // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
+ // watch on a conversation's live turn stream WITHOUT opening a tab. Separate
+ // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
+ // and a tab's conversation reuses its own store (see `watchConversation`).
+ // Deltas are routed here in addition to `chatStores`.
+ const watchStores = 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);
+
+ // The GLOBAL vision settings (shared across all conversations). Seeded on
+ // boot; null = not yet fetched.
+ let visionSettings = $state<VisionSettings | null>(null);
+
+ /** Refetch the global vision settings (`GET /settings/vision`). */
+ async function refreshVisionSettings(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/settings/vision`);
+ if (!res.ok) return;
+ const data = normalizeVisionSettings(await res.json());
+ visionSettings = data;
+ } catch (err) {
+ reportError("Failed to load vision settings", err);
+ }
+ }
+
+ /** 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) ?? watchStores.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);
+ }
+ for (const store of watchStores.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 });
+ }
+
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
+ * If the conversation is already an open TAB, reuses its store (it is already
+ * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
+ * `watchStores` (separate from tabs — never opens a tab), subscribes to its
+ * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
+ * Pair with {@link unwatchConversation} on close.
+ */
+ function watchConversation(conversationId: string): ChatStore {
+ // An open tab already has a live store + subscription — reuse it.
+ const tabStore = chatStores.get(conversationId);
+ if (tabStore !== undefined) return tabStore;
+ const existing = watchStores.get(conversationId);
+ if (existing !== undefined) return existing;
+ const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
+ watchStores.set(conversationId, store);
+ void store.load();
+ subscribeChat(conversationId);
+ return store;
+ }
+
+ /**
+ * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
+ * the conversation was (or became) an open TAB — the tab owns its store +
+ * subscription, so nothing is torn down (closing the modal must not disturb the
+ * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
+ */
+ function unwatchConversation(conversationId: string): void {
+ // A tab reuses its own store — leave it (and its subscription) intact.
+ if (chatStores.has(conversationId)) return;
+ const store = watchStores.get(conversationId);
+ if (store === undefined) return;
+ store.dispose();
+ watchStores.delete(conversationId);
+ unsubscribeChat(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());
+
+ // The set of workspaces with ≥1 active/queued conversation, derived ONCE
+ // (not recomputed per card). Every active/queued conversation has an open
+ // tab stamped with its workspace, so the tabs are the conversation→workspace
+ // map; cross-reference with the lifecycle statuses. `$derived` recomputes
+ // lazily when the tab set or status map changes, so each
+ // `workspaceHasActiveConversations` call is an O(1) lookup instead of a scan
+ // of the full tab list per card.
+ const activeWorkspaces = $derived.by(() => {
+ const out = new Set<string>();
+ for (const tab of tabsStore.tabs) {
+ const status = conversationStatuses.get(tab.conversationId);
+ if (status === "active" || status === "queued") out.add(tab.workspaceId);
+ }
+ return out;
+ });
+
+ /**
+ * 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 / queued / idle — update the status map (drives the tab spinner).
+ // `queued` = the turn is in flight but waiting for a concurrency slot
+ // (broadcast-only, never persisted — CR-13); the tab shows a ring.
+ conversationStatuses = new Map(conversationStatuses).set(conversationId, status);
+ // If this is a new active OR queued conversation we don't have a tab for,
+ // open one — so a cross-device turn (incl. one waiting in the concurrency
+ // queue) is visible. `idle` never opens a tab.
+ if ((status === "active" || status === "queued") && !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();
+ }
+ // Re-attach to every MODAL watch too (a run-chat modal open across a
+ // reconnect keeps streaming). Watch stores are separate from tabs.
+ for (const [watchId, watchStore] of watchStores) {
+ subscribeChat(watchId);
+ watchStore.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();
+ void refreshVisionSettings();
+
+ // 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;
+ },
+ get httpBase(): string {
+ return httpBase;
+ },
+ 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 visionSettings(): VisionSettings | null {
+ return visionSettings;
+ },
+ async refreshVisionSettings(): Promise<void> {
+ await refreshVisionSettings();
+ },
+ get chatLimit(): number {
+ return chatLimit;
+ },
+ conversationStatus(conversationId: string): ConversationStatus | undefined {
+ return conversationStatuses.get(conversationId);
+ },
+ workspaceHasActiveConversations(workspaceId: string): boolean {
+ // O(1) lookup into the once-derived `activeWorkspaces` set; false when the
+ // workspace has no active/queued conversation (or none at all).
+ return activeWorkspaces.has(workspaceId);
+ },
+ get currentConversationId(): string {
+ return workspaceConversationId();
+ },
+
+ surface(surfaceId: string): SurfaceSpec | null {
+ return getSurfaceSpec(protocol, surfaceId);
+ },
+
+ send(text: string, images?: readonly ImageInput[]): 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, images);
+ } else {
+ activeChat.send(text, images);
+ }
+ },
+
+ 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 setVisionSettings(patch: VisionSettingsPatch): Promise<VisionSettingsResult | null> {
+ const body: SetVisionSettingsRequest = patch;
+ try {
+ const res = await fetchImpl(`${httpBase}/settings/vision`, {
+ 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 vision settings failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeVisionSettings(await res.json());
+ visionSettings = data;
+ return { ok: true, settings: data };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set vision settings 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 heartbeatConfig(): Promise<HeartbeatConfigResult> {
+ // Workspace-scoped (NOT per-conversation): use the active workspace id.
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat config request failed",
+ };
+ }
+ },
+
+ async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(patch),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set heartbeat config request failed",
+ };
+ }
+ },
+
+ async heartbeatRuns(): Promise<HeartbeatRunsResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
+ };
+ }
+ const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
+ return { ok: true, runs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat runs request failed",
+ };
+ }
+ },
+
+ async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
+ { method: "POST" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
+ };
+ }
+ },
+
+ async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
+ );
+ if (!res.ok) {
+ // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
+ // an approximation. Surface as ok:false (non-fatal).
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
+ // `null` (disabled / no run scheduled) passes through; anything non-string
+ // also becomes null so a malformed body can't crash the countdown.
+ const raw = data?.nextRunAt;
+ const nextRunAt = typeof raw === "string" ? raw : null;
+ return { ok: true, nextRunAt };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
+ };
+ }
+ },
+
+ watchConversation(conversationId: string): ChatStore {
+ return watchConversation(conversationId);
+ },
+
+ unwatchConversation(conversationId: string): void {
+ unwatchConversation(conversationId);
+ },
+
+ // ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped)
+
+ async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/limits`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response (e.g. the extension returning `{}`) can
+ // never crash the renderer.
+ const limits = normalizeConcurrencyLimits(await res.json());
+ return { ok: true, limits };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limits request failed",
+ };
+ }
+ },
+
+ async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const limit = normalizeConcurrencyLimit(await res.json());
+ if (limit === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: limit.providerId, limit: limit.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limit request failed",
+ };
+ }
+ },
+
+ async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ limit }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const echoed = normalizeConcurrencyLimit(await res.json());
+ if (echoed === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency limit request failed",
+ };
+ }
+ },
+
+ async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true, providerId };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
+ };
+ }
+ },
+
+ async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
+ };
+ }
+ const providers = normalizeConcurrencyStatus(await res.json());
+ return { ok: true, providers };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency status request failed",
+ };
+ }
+ },
+
+ async getConcurrencyCooldown(providerId: string): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Get concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Get concurrency cooldown request failed",
+ };
+ }
+ },
+
+ async setConcurrencyCooldown(
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/cooldown/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ cooldownMs }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency cooldown failed (HTTP ${res.status})`,
+ };
+ }
+ const data = normalizeConcurrencyCooldown(await res.json());
+ if (data === null) {
+ return { ok: false, error: "Malformed concurrency cooldown response" };
+ }
+ return { ok: true, providerId: data.providerId, cooldownMs: data.cooldownMs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency cooldown 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();
+ for (const store of watchStores.values()) {
+ store.dispose();
+ }
+ watchStores.clear();
+ draftStore.dispose();
+ socket?.close();
+ socket = null;
+ },
+ };
}
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 86a21d6..a945ea7 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,751 +1,2089 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
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;
+}
+
+/**
+ * A fake socket that supports the close→reconnect cycle (the base `fakeSocket`
+ * swallows `onclose`). The factory returns the SAME instance on every connect, so
+ * `sent` accumulates and `open()` can be driven again after `closeRemote()`.
+ */
+interface ReconnectableSocket extends WebSocketLike {
+ 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: "" });
+ },
+ };
}
interface FakeFetchOptions {
- models?: readonly string[];
- history?: Record<string, ConversationHistoryResponse>;
+ 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 });
- }
- 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: FakeSocket): unknown[] {
- return ws.sent.map((s) => JSON.parse(s));
+function parseSent(ws: { sent: string[] }): unknown[] {
+ 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 selection", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.catalog).toEqual([]);
- expect(store.selectedId).toBeNull();
- expect(store.selectedSpec).toBeNull();
- 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("select sends subscribe and sets selectedId", () => {
- 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" }],
- });
-
- ws.sent.length = 0;
- store.select("s1");
-
- expect(store.selectedId).toBe("s1");
- const subscribeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return parsed.type === "subscribe" && parsed.surfaceId === "s1";
- });
- expect(subscribeMsg).toBeTruthy();
-
- store.dispose();
- });
-
- it("selecting a different surface unsubscribes from previous", () => {
- 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;
- store.select("s1");
- store.select("s2");
-
- const unsubscribeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return parsed.type === "unsubscribe" && parsed.surfaceId === "s1";
- });
- expect(unsubscribeMsg).toBeTruthy();
-
- const subscribeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return parsed.type === "subscribe" && parsed.surfaceId === "s2";
- });
- expect(subscribeMsg).toBeTruthy();
-
- store.dispose();
- });
-
- it("surface message updates selectedSpec", () => {
- 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" }],
- });
-
- store.select("s1");
-
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
- },
- });
-
- expect(store.selectedSpec).not.toBeNull();
- expect(store.selectedSpec?.id).toBe("s1");
- expect(store.selectedSpec?.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 updates the active tab", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("hello");
-
- store.selectModel("openai/gpt-4o");
-
- 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("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("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("sending from draft forwards staged images on chat.send", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ ws.sent.length = 0;
+
+ const images = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/x.jpg" },
+ ];
+ store.send("describe these", images);
+
+ const msgs = parseSent(ws);
+ const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as
+ | { type: string; message: string; images?: { url: string }[] }
+ | undefined;
+ expect(chatSend).toBeTruthy();
+ expect(chatSend?.images).toEqual(images);
+ // The optimistic echo includes the image chunks.
+ expect(store.activeChat.chunks.some((c) => c.chunk.type === "image")).toBe(true);
+
+ 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" },
+ });
+
+ // `turn-sealed` triggers an async `syncTail` (cache.sinceSeq → historySync
+ // → cache.commit → applyHistory). Poll for the side-effect rather than
+ // guessing a fixed delay — under suite load a fixed `setTimeout` raced the
+ // fetch chain and flaked here.
+ await vi.waitFor(() => {
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+ });
+
+ await vi.waitFor(() => {
+ 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();
+ });
+
+ // ── Heartbeat (workspace-scoped config + runs + watch) ───────────────────────
+ //
+ // The heartbeat API is a plain REST surface (not a transport-contract type),
+ // so these tests fake the four endpoints + verify the store coerces the
+ // untyped JSON and routes live deltas to a watch store (the run-chat modal).
+
+ function heartbeatFetchImpl(opts?: {
+ config?: Record<string, unknown>;
+ runs?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const config = opts?.config ?? {
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ };
+ const runs = opts?.runs ?? {
+ runs: [
+ {
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ triggeredAt: "2026-06-25T10:00:00Z",
+ status: "running",
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs") && method === "GET") {
+ return new Response(JSON.stringify(runs), { status: 200 });
+ }
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "GET") {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ // Echo the patch merged onto the stored config so the round-trip is observable.
+ const patch = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ ...config, ...patch }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/heartbeat")) {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("heartbeatConfig loads + coerces the workspace config", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ });
+ store.dispose();
+ });
+
+ it("heartbeatConfig surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/heartbeat"))
+ return new Response(JSON.stringify({ error: "nope" }), { status: 500 });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("nope");
+ store.dispose();
+ });
+
+ it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 });
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 });
+ // The store normalizes the echoed response (interval clamped to the 1–1440 range).
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config.intervalMinutes).toBe(1440);
+ store.dispose();
+ });
+
+ it("heartbeatRuns loads + coerces the run list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatRuns();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.runs).toHaveLength(1);
+ expect(result.runs[0]).toMatchObject({
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ status: "running",
+ });
+ store.dispose();
+ });
+
+ it("stopHeartbeatRun POSTs the stop endpoint", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ calls.push({ url, method });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.stopHeartbeatRun("run-1");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop");
+ expect(calls[0]?.method).toBe("POST");
+ store.dispose();
+ });
+
+ it("watchConversation subscribes + routes live deltas to the watch store", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A heartbeat run's conversation that is NOT an open tab — watch it.
+ const watch = store.watchConversation("hb-conv-watch");
+ // A chat.subscribe was sent for the watched conversation.
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(subscribed).toBe(true);
+
+ // Feed a live delta for the watched conversation → the watch store folds it.
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: "hb-conv-watch",
+ turnId: "t1",
+ delta: "hello from heartbeat",
+ },
+ });
+
+ await vi.waitFor(() => {
+ const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text");
+ expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe(
+ "hello from heartbeat",
+ );
+ });
+ expect(watch.generating).toBe(true);
+
+ // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent).
+ ws.sent.length = 0;
+ store.unwatchConversation("hb-conv-watch");
+ const unsubscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(unsubscribed).toBe(true);
+
+ store.dispose();
+ });
+
+ it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ // The conversation is an open tab (already subscribed on send). Watching it
+ // must REUSE the tab's store + subscription — so no NEW chat.subscribe is
+ // sent (the watch path only subscribes when it creates an ephemeral store).
+ // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a
+ // reference-equality check is meaningless here — we assert behavior instead.)
+ ws.sent.length = 0;
+ store.watchConversation(convId);
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === convId,
+ );
+ expect(subscribed).toBe(false);
+
+ // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream).
+ ws.sent.length = 0;
+ store.unwatchConversation(convId);
+ const unsubscribed = parseSent(ws).some(
+ (p) => (p as { type: string }).type === "chat.unsubscribe",
+ );
+ expect(unsubscribed).toBe(false);
+
+ store.dispose();
+ });
+
+ // ── Concurrency (per-provider limits + live status; GLOBAL REST surface) ─────
+ //
+ // The concurrency API is a plain REST surface under /concurrency (provided by
+ // the `concurrency` extension). These tests fake all five endpoints + verify
+ // the store coerces the untyped JSON and routes the right method/URL.
+
+ function concurrencyFetchImpl(opts?: {
+ limits?: Record<string, unknown>;
+ limit?: Record<string, unknown>;
+ status?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const limits = opts?.limits ?? {
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ };
+ const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
+ const status = opts?.status ?? {
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: 1_719_408_000_000,
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/concurrency/status") && method === "GET") {
+ return new Response(JSON.stringify(status), { status: 200 });
+ }
+ if (url.endsWith("/concurrency/limits") && method === "GET") {
+ return new Response(JSON.stringify(limits), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "GET") {
+ return new Response(JSON.stringify(limit), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "PUT") {
+ const seg = url.slice(
+ url.lastIndexOf("/concurrency/limits/") + "/concurrency/limits/".length,
+ );
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ providerId: seg, ...body }), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "DELETE") {
+ return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 });
+ }
+ if (url.includes("/concurrency/cooldown/") && method === "GET") {
+ return new Response(JSON.stringify({ providerId: "umans", cooldownMs: 350 }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/concurrency/cooldown/") && method === "PUT") {
+ const seg = url.slice(
+ url.lastIndexOf("/concurrency/cooldown/") + "/concurrency/cooldown/".length,
+ );
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ providerId: seg, cooldownMs: body.cooldownMs ?? 0 }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("concurrencyLimits loads + coerces the limits list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ store.dispose();
+ });
+
+ it("concurrencyLimits tolerates a malformed/empty body (extension not loaded)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl({ limits: {} }),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.limits).toEqual([]);
+ store.dispose();
+ });
+
+ it("concurrencyLimits surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/concurrency/limits"))
+ return new Response(JSON.stringify({ error: "Concurrency service not available" }), {
+ status: 503,
+ });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("Concurrency service not available");
+ store.dispose();
+ });
+
+ it("getConcurrencyLimit loads one provider's limit", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyLimit("umans");
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ expect(result.limit).toBe(4);
+ store.dispose();
+ });
+
+ it("getConcurrencyLimit surfaces a 404 (not configured)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "GET")
+ return new Response(
+ JSON.stringify({ error: "No concurrency limit configured for this provider" }),
+ { status: 404 },
+ );
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyLimit("anthropic");
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("No concurrency limit configured");
+ store.dispose();
+ });
+
+ it("setConcurrencyLimit PUTs { limit } to the provider URL and returns the echoed limit", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return concurrencyFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyLimit("anthropic", 8);
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.method).toBe("PUT");
+ expect(calls[0]?.url).toContain("/concurrency/limits/anthropic");
+ expect(calls[0]?.body).toEqual({ limit: 8 });
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("anthropic"); // echoed by the fake
+ expect(result.limit).toBe(8);
+ store.dispose();
+ });
+
+ it("setConcurrencyLimit surfaces a 400 (non-positive body)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/limits/") && init?.method === "PUT")
+ return new Response(
+ JSON.stringify({ error: "Body must be { limit: <positive integer> }" }),
+ {
+ status: 400,
+ },
+ );
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyLimit("umans", 0);
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("Body must be");
+ store.dispose();
+ });
+
+ it("deleteConcurrencyLimit DELETEs the provider URL and returns ok", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "DELETE") {
+ calls.push({ url, method });
+ }
+ return concurrencyFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.deleteConcurrencyLimit("umans");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.method).toBe("DELETE");
+ expect(calls[0]?.url).toContain("/concurrency/limits/umans");
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ store.dispose();
+ });
+
+ it("concurrencyStatus loads + coerces the status list (incl. pausedUntil)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyStatus();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providers).toHaveLength(2);
+ expect(result.providers[0]).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ });
+ expect(result.providers[1]).toMatchObject({
+ providerId: "openai-compat",
+ paused: true,
+ pausedUntil: 1_719_408_000_000,
+ cooldownMs: 350,
+ autoReduced: false,
+ });
+ store.dispose();
+ });
+
+ it("concurrencyStatus tolerates a malformed body (extension not loaded)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl({ status: {} }),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyStatus();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providers).toEqual([]);
+ store.dispose();
+ });
+
+ it("getConcurrencyCooldown loads + coerces the cooldown", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyCooldown("umans");
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ expect(result.cooldownMs).toBe(350);
+ store.dispose();
+ });
+
+ it("getConcurrencyCooldown surfaces a 404 (no concurrency config) as ok:false", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/cooldown/")) {
+ return new Response(
+ JSON.stringify({ error: "No concurrency configuration for this provider" }),
+ {
+ status: 404,
+ },
+ );
+ }
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyCooldown("ghost");
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("No concurrency configuration");
+ store.dispose();
+ });
+
+ it("setConcurrencyCooldown PUTs { cooldownMs } + returns the echoed value", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ calls.push({ url, method, body: init?.body ? JSON.parse(init.body as string) : null });
+ if (url.includes("/concurrency/cooldown/") && method === "PUT") {
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(
+ JSON.stringify({ providerId: "umans", cooldownMs: body.cooldownMs }),
+ { status: 200 },
+ );
+ }
+ return fakeFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyCooldown("umans", 500);
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.cooldownMs).toBe(500);
+ const cooldownCall = calls.find(
+ (c) => c.url.includes("/concurrency/cooldown/") && c.method === "PUT",
+ );
+ expect(cooldownCall?.url).toContain("/concurrency/cooldown/umans");
+ expect(cooldownCall?.body).toEqual({ cooldownMs: 500 });
+ store.dispose();
+ });
+
+ it("setConcurrencyCooldown surfaces a 400 (invalid body) as ok:false", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/cooldown/") && init?.method === "PUT") {
+ return new Response(
+ JSON.stringify({ error: "Body must be { cooldownMs: <non-negative integer> }" }),
+ { status: 400 },
+ );
+ }
+ return fakeFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyCooldown("umans", -1);
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("non-negative integer");
+ store.dispose();
+ });
+
+ // ── Conversation status: `queued` (CR-13 — waiting for a concurrency slot) ────
+
+ it("conversation.statusChanged 'queued' sets the status (tab spinner) without opening a duplicate tab", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+ const tabsBefore = store.tabs.length;
+
+ // The backend broadcasts "queued" while the turn waits for a slot.
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "queued",
+ workspaceId: "default",
+ });
+
+ expect(store.conversationStatus(convId)).toBe("queued");
+ // The tab already exists (opened on send) — no duplicate tab is opened.
+ expect(store.tabs.length).toBe(tabsBefore);
+
+ // Granted → "active" (dots), then idle on turn seal.
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+ expect(store.conversationStatus(convId)).toBe("active");
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "idle",
+ workspaceId: "default",
+ });
+ expect(store.conversationStatus(convId)).toBe("idle");
+ store.dispose();
+ });
+
+ it("conversation.statusChanged 'queued' opens a tab for a new cross-device conversation", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ expect(store.tabs.length).toBe(0);
+
+ // Another device's turn is waiting for a slot — broadcast "queued".
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "other-device-conv",
+ status: "queued",
+ workspaceId: "default",
+ });
+
+ expect(store.conversationStatus("other-device-conv")).toBe("queued");
+ // A queued conversation we had no tab for opens one (like `active`).
+ expect(store.tabs.some((t) => t.conversationId === "other-device-conv")).toBe(true);
+ store.dispose();
+ });
+
+ // ── workspaceHasActiveConversations (workspace-card active indicator) ─────
+
+ it("workspaceHasActiveConversations is false when no conversation is active", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ // The conversation is freshly created — the backend hasn't reported it as
+ // active yet, so the workspace has no active conversation.
+ expect(store.conversationStatus(convId)).toBeUndefined();
+ expect(store.workspaceHasActiveConversations("default")).toBe(false);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations is true when a conversation in the workspace is active", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations is true when a conversation is queued (waiting for a slot)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "queued",
+ workspaceId: "default",
+ });
+
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations goes back to false when the conversation goes idle", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ store.send("hello");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "active",
+ workspaceId: "default",
+ });
+ expect(store.workspaceHasActiveConversations("default")).toBe(true);
+
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: convId,
+ status: "idle",
+ workspaceId: "default",
+ });
+ expect(store.workspaceHasActiveConversations("default")).toBe(false);
+ store.dispose();
+ });
+
+ it("workspaceHasActiveConversations scopes to the given workspace (ignores other workspaces)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A cross-device active conversation in workspace "proj-a".
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "proj-a-conv",
+ status: "active",
+ workspaceId: "proj-a",
+ });
+
+ // proj-a is active; proj-b is not (no active conversation there).
+ expect(store.workspaceHasActiveConversations("proj-a")).toBe(true);
+ expect(store.workspaceHasActiveConversations("proj-b")).toBe(false);
+ store.dispose();
+ });
+});
+
+describe("createAppStore — vision settings (global)", () => {
+ function visionFetch(initial: { imageLimit: number; compactionModel: string | null }): {
+ fetchImpl: typeof fetch;
+ puts: { imageLimit?: number; compactionModel?: string | null }[];
+ } {
+ let current = initial;
+ const puts: { imageLimit?: number; compactionModel?: string | null }[] = [];
+ return {
+ puts,
+ fetchImpl: 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("/models")) {
+ return new Response(
+ JSON.stringify({
+ models: ["kimi/k2", "umans/glm-5.2"],
+ modelInfo: { "kimi/k2": { vision: true } },
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/settings/vision")) {
+ if (init?.method === "PUT") {
+ const text = typeof init.body === "string" ? init.body : "";
+ const body = text ? (JSON.parse(text) as object) : {};
+ puts.push(body as { imageLimit?: number; compactionModel?: string | null });
+ current = { ...current, ...(body as object) } as {
+ imageLimit: number;
+ compactionModel: string | null;
+ };
+ }
+ return new Response(JSON.stringify(current), { status: 200 });
+ }
+ // Default: empty history + no cwd for the other endpoints.
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ },
+ };
+ }
+
+ it("loads vision settings on boot (GET /settings/vision)", async () => {
+ const { fetchImpl } = visionFetch({ imageLimit: 7, compactionModel: "kimi/k2" });
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ fakeSocket().resolveOpen(); // not strictly needed for HTTP
+
+ await vi.waitFor(() => {
+ expect(store.visionSettings).toEqual({ imageLimit: 7, compactionModel: "kimi/k2" });
+ });
+ store.dispose();
+ });
+
+ it("setVisionSettings PUTs a partial update and reflects the merged settings", async () => {
+ const ctx = visionFetch({ imageLimit: 10, compactionModel: null });
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: ctx.fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+
+ await vi.waitFor(() => {
+ expect(store.visionSettings?.imageLimit).toBe(10);
+ });
+
+ const result = await store.setVisionSettings({ imageLimit: 3 });
+ expect(result?.ok).toBe(true);
+ if (result?.ok) {
+ expect(result.settings.imageLimit).toBe(3);
+ expect(result.settings.compactionModel).toBeNull();
+ }
+ expect(ctx.puts).toEqual([{ imageLimit: 3 }]);
+ expect(store.visionSettings?.imageLimit).toBe(3);
+
+ // A second save updates compactionModel only.
+ const result2 = await store.setVisionSettings({ compactionModel: "kimi/k2" });
+ expect(result2?.ok).toBe(true);
+ expect(ctx.puts).toEqual([{ imageLimit: 3 }, { compactionModel: "kimi/k2" }]);
+ expect(store.visionSettings?.compactionModel).toBe("kimi/k2");
+ store.dispose();
+ });
+
+ it("refreshVisionSettings refetches (load adapter)", async () => {
+ const { fetchImpl } = visionFetch({ imageLimit: 5, compactionModel: null });
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+
+ await store.refreshVisionSettings();
+ expect(store.visionSettings).toEqual({ imageLimit: 5, compactionModel: null });
+ store.dispose();
+ });
+
+ it("surfaces a PUT error", async () => {
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/settings/vision")) {
+ if (init?.method === "PUT") {
+ return new Response(JSON.stringify({ error: "invalid imageLimit" }), { status: 400 });
+ }
+ return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ models: [] }), { status: 200 });
+ };
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+
+ const result = await store.setVisionSettings({ imageLimit: -1 });
+ expect(result?.ok).toBe(false);
+ if (result !== null && !result.ok) {
+ expect(result.error).toContain("invalid imageLimit");
+ }
+ 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
new file mode 100644
index 0000000..b6f5c5a
--- /dev/null
+++ b/src/components/Table.svelte
@@ -0,0 +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();
+</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>
+</div>
diff --git a/src/components/Table.test.ts b/src/components/Table.test.ts
new file mode 100644
index 0000000..f43a981
--- /dev/null
+++ b/src/components/Table.test.ts
@@ -0,0 +1,35 @@
+import { render, screen, within } from "@testing-library/svelte";
+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 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();
+ });
+});
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
new file mode 100644
index 0000000..c8b0fa2
--- /dev/null
+++ b/src/core/chunks/groups.test.ts
@@ -0,0 +1,125 @@
+import type { Role, StepId } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+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,
+});
+
+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,
+});
+
+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,
+});
+
+describe("groupRenderedChunks", () => {
+ 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("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("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("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");
+ });
+});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
new file mode 100644
index 0000000..53a2873
--- /dev/null
+++ b/src/core/chunks/groups.ts
@@ -0,0 +1,95 @@
+import type { ToolCallChunk, ToolResultChunk } from "@dispatch/wire";
+import type { RenderedChunk } from "./types";
+
+/**
+ * One tool call within a batch, paired with its result (matched by `toolCallId`).
+ * `result` is null while the call is still pending (no result chunk yet).
+ */
+export interface ToolBatchEntry {
+ readonly call: ToolCallChunk;
+ readonly result: ToolResultChunk | null;
+}
+
+/**
+ * A render group: either a single rendered chunk (rendered as today) or a batch
+ * of tool calls the model emitted together in one step (shared `stepId`), to be
+ * 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;
+ };
+
+/**
+ * Group a flat rendered-chunk stream for display. Tool calls sharing a `stepId`
+ * (the backend's authoritative batch key) where the step has 2+ calls become one
+ * `tool-batch` group, positioned at the first call and pairing each call with its
+ * `tool-result` (by `toolCallId`); the absorbed result chunks are not emitted on
+ * their own. Single tool calls (one per step, or no `stepId` — e.g. pre-0.2.0
+ * replay rows) and every non-tool chunk render as `single` groups, in order.
+ *
+ * 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);
+ }
+
+ // 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;
+
+ 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;
+ }
+
+ if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
+ continue; // absorbed into its batch
+ }
+
+ groups.push({ kind: "single", chunk: rc });
+ }
+
+ return groups;
+}
diff --git a/src/core/chunks/image-url.test.ts b/src/core/chunks/image-url.test.ts
new file mode 100644
index 0000000..8a79c09
--- /dev/null
+++ b/src/core/chunks/image-url.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { resolveImageUrl } from "./image-url";
+
+const BASE = "http://localhost:24203";
+
+describe("resolveImageUrl", () => {
+ it("returns a data URL as-is (the optimistic echo / a pasted image)", () => {
+ const dataUrl = "data:image/png;base64,iVBORw0KGgo=";
+ expect(resolveImageUrl(dataUrl, BASE)).toBe(dataUrl);
+ });
+
+ it("returns an absolute http URL as-is", () => {
+ const abs = "https://example.com/img.png";
+ expect(resolveImageUrl(abs, BASE)).toBe(abs);
+ });
+
+ it("prepends the api base to a relative /images/ path", () => {
+ expect(resolveImageUrl("/images/conv-123/abc-456.png", BASE)).toBe(
+ "http://localhost:24203/images/conv-123/abc-456.png",
+ );
+ });
+
+ it("does not double the slash when the base has a trailing slash", () => {
+ expect(resolveImageUrl("/images/c/x.png", "http://localhost:24203/")).toBe(
+ "http://localhost:24203/images/c/x.png",
+ );
+ });
+
+ it("adds a leading slash to a path-relative url without one", () => {
+ expect(resolveImageUrl("images/c/x.png", BASE)).toBe("http://localhost:24203/images/c/x.png");
+ });
+
+ it("returns the relative path as-is when apiBase is empty (root-relative)", () => {
+ // A browser resolves a root-relative `/images/…` against the document origin.
+ expect(resolveImageUrl("/images/c/x.png", "")).toBe("/images/c/x.png");
+ });
+
+ it("handles a relative path with an empty apiBase (path-relative without slash)", () => {
+ expect(resolveImageUrl("images/c/x.png", "")).toBe("/images/c/x.png");
+ });
+
+ it("returns a data URL as-is even with an empty apiBase", () => {
+ const dataUrl = "data:image/jpeg;base64,AAAA";
+ expect(resolveImageUrl(dataUrl, "")).toBe(dataUrl);
+ });
+});
diff --git a/src/core/chunks/image-url.ts b/src/core/chunks/image-url.ts
new file mode 100644
index 0000000..e5ec756
--- /dev/null
+++ b/src/core/chunks/image-url.ts
@@ -0,0 +1,35 @@
+/**
+ * Resolve an `ImageChunk.url` into a renderable `<img src>` value.
+ *
+ * Persisted image chunks now carry a COMPACT HTTP path
+ * (`/images/<conversationId>/<uuid>.png`) served by the backend — NOT a base64
+ * data URL (images are stored on disk under tmp, not in the conversation store,
+ * to keep SQLite payloads small). The optimistic echo (what the FE just sent in
+ * `ChatRequest.images`) still carries a data URL, and a chunk could also carry
+ * an absolute `http(s)://` URL, so the resolution is format-aware:
+ *
+ * - `data:` URL → returned as-is (the optimistic echo / a pasted data URL).
+ * - `http(s)://` → returned as-is (an absolute URL already).
+ * - anything else (a relative path like `/images/…`) → `apiBase` is prepended
+ * (with no double slash). An empty `apiBase` leaves a root-relative path,
+ * which a browser resolves against the document origin.
+ *
+ * Pure: input → output, zero DOM, zero Svelte.
+ *
+ * @param url The chunk's `url` (data URL, absolute, or relative path).
+ * @param apiBase The HTTP API base URL (e.g. `http://localhost:24203`).
+ */
+export function resolveImageUrl(url: string, apiBase: string): string {
+ if (url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://")) {
+ return url;
+ }
+ // A relative path (e.g. `/images/…`) — normalize to a leading slash and
+ // prepend the api base. With an empty base this yields a root-relative path
+ // (a browser resolves `/images/…` against the document origin).
+ const path = url.startsWith("/") ? url : `/${url}`;
+ if (apiBase.length === 0) return path;
+ // Join without a double slash: strip a trailing slash from the base, then
+ // append the (leading-slash) path verbatim.
+ const base = apiBase.endsWith("/") ? apiBase.slice(0, -1) : apiBase;
+ return `${base}${path}`;
+}
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index 67739bc..bdd6ce4 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -1,8 +1,31 @@
-export { appendUserMessage, applyHistory, foldEvent, initialState } from "./reducer";
-export { selectChunks, selectMessages } from "./selectors";
+export type { RenderGroup, ToolBatchEntry } from "./groups";
+export { groupRenderedChunks } from "./groups";
+export { resolveImageUrl } from "./image-url";
+export {
+ 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,
+} 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 b7165e4..058552e 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -1,467 +1,1024 @@
import type {
- StoredChunk,
- TurnDoneEvent,
- TurnErrorEvent,
- TurnReasoningDeltaEvent,
- TurnSealedEvent,
- TurnStartEvent,
- TurnTextDeltaEvent,
- TurnToolCallEvent,
- TurnToolResultEvent,
- TurnUsageEvent,
+ ImageInput,
+ 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, foldEvent, initialState } from "./reducer";
-import { selectChunks, selectMessages } from "./selectors";
+import {
+ 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,
+ turnId: string,
+ toolCallId: string,
+ toolName: string,
+ input: unknown,
+ stepId = "s0",
): TurnToolCallEvent => ({
- type: "tool-call",
- conversationId: "c1",
- turnId,
- toolCallId,
- toolName,
- input,
+ type: "tool-call",
+ conversationId: "c1",
+ turnId,
+ toolCallId,
+ toolName,
+ input,
+ stepId: stepId as StepId,
});
const toolResult = (
- turnId: string,
- toolCallId: string,
- toolName: string,
- content: string,
+ turnId: string,
+ toolCallId: string,
+ toolName: string,
+ content: string,
+ stepId = "s0",
): TurnToolResultEvent => ({
- type: "tool-result",
- conversationId: "c1",
- turnId,
- toolCallId,
- toolName,
- content,
- isError: false,
+ 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,
+): TurnProviderRetryEvent =>
+ 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();
- });
+ 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);
+ });
+});
+
+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");
+ });
});
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" }));
- s = foldEvent(s, toolResult("t1", "tc1", "bash", "file.txt"));
- expect(s.provisional).toHaveLength(2);
- expect(s.provisional[0]?.role).toBe("assistant");
- expect(s.provisional[0]?.chunk).toEqual({
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "bash",
- input: { cmd: "ls" },
- });
- 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,
- });
- });
-
- 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();
+ });
+});
+
+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();
+ });
+});
+
+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"]);
+ });
+});
+
+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"]);
+ });
});
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("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("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" });
+ });
+});
+
+const PNG = "data:image/png;base64,AAAA";
+const JPG = "data:image/jpeg;base64,BBBB";
+
+describe("appendUserMessage — images (vision handoff)", () => {
+ it("echoes text then image chunks in order", () => {
+ const images: ImageInput[] = [
+ { url: PNG, mimeType: "image/png" },
+ { url: JPG, mimeType: "image/jpeg" },
+ ];
+ let s = initialState();
+ s = appendUserMessage(s, "what's this?", images);
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(3);
+ expect(chunks[0]?.role).toBe("user");
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "what's this?" });
+ expect(chunks[1]?.chunk).toEqual({ type: "image", url: PNG, mimeType: "image/png" });
+ expect(chunks[2]?.chunk).toEqual({ type: "image", url: JPG, mimeType: "image/jpeg" });
+ expect(chunks.every((c) => c.provisional && c.seq === null)).toBe(true);
+ });
+
+ it("omits mimeType when not provided", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "look", [{ url: PNG }]);
+ const img = selectChunks(s)[1]?.chunk;
+ expect(img).toEqual({ type: "image", url: PNG });
+ expect(img).not.toHaveProperty("mimeType");
+ });
+
+ it("echoes images-only when text is empty (no text chunk)", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "", [{ url: PNG, mimeType: "image/png" }]);
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(1);
+ expect(chunks[0]?.chunk.type).toBe("image");
+ });
+
+ it("echoes nothing for empty text + empty images", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "partial"));
+ s = appendUserMessage(s, "", []);
+ // Defensive flush still happened; no user chunk added.
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(0);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("skips images with an empty url", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi", [
+ { url: "", mimeType: "image/png" },
+ { url: PNG, mimeType: "image/png" },
+ ]);
+ const chunks = selectChunks(s);
+ expect(chunks).toHaveLength(2); // text + the one valid image
+ expect(chunks[1]?.chunk.type).toBe("image");
+ });
+
+ it("groups text + images into one user ChatMessage", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "see this", [{ url: PNG }]);
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(1);
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(2);
+ });
+});
+
+describe("foldEvent — user-message dedups against a text+image echo", () => {
+ const userMessage = (text: string): TurnInputEvent => ({
+ type: "user-message",
+ conversationId: "c1",
+ turnId: "t1",
+ text,
+ });
+
+ it("does not duplicate the text when images follow it in the echo", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi", [{ url: PNG, mimeType: "image/png" }]);
+ expect(selectChunks(s)).toHaveLength(2); // text + image
+ s = foldEvent(s, userMessage("hi"));
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users).toHaveLength(2); // unchanged — no duplicate text
+ expect(users[0]?.chunk.type).toBe("text");
+ expect(users[1]?.chunk.type).toBe("image");
+ expect(s.generating).toBe(true);
+ });
+
+ it("still appends when the echoed text differs", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "first", [{ url: PNG }]);
+ s = foldEvent(s, userMessage("second"));
+ const users = selectChunks(s).filter((c) => c.role === "user");
+ expect(users.filter((c) => c.chunk.type === "text")).toHaveLength(2);
+ });
+});
+
+describe("applyHistory — multi-chunk image echo is superseded by committed", () => {
+ it("drops the provisional [text, image] echo when committed arrives during generation", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi", [{ url: PNG, mimeType: "image/png" }]);
+ s = foldEvent(s, turnStart("t1"));
+ expect(s.provisional).toHaveLength(2);
+
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "hi" }),
+ storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }),
+ ]);
+ expect(s.provisional).toEqual([]);
+ expect(s.committed).toHaveLength(2);
+ expect(s.committed[0]?.chunk).toEqual({ type: "text", text: "hi" });
+ expect(s.committed[1]?.chunk).toEqual({ type: "image", url: PNG, mimeType: "image/png" });
+ });
+
+ it("keeps the echo when committed is only a partial match (img not yet persisted)", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "hi", [
+ { url: PNG, mimeType: "image/png" },
+ { url: JPG, mimeType: "image/jpeg" },
+ ]);
+ s = foldEvent(s, turnStart("t1"));
+ // Only the text + first image have been persisted so far.
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "hi" }),
+ storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }),
+ ]);
+ // Echo is [text, img1, img2]; committed run is [text, img1] — not a full
+ // match (echo is longer) → keep the echo (turn-seal will drop it wholesale).
+ expect(s.provisional.length).toBeGreaterThan(0);
+ });
+
+ it("renders committed image chunks from history (a non-vision transcription turn)", () => {
+ // The server persists the original image chunk AND the transcription text
+ // in the SAME user message: render both (image, then analysis text).
+ let s = initialState();
+ s = applyHistory(s, [
+ storedChunk(1, "user", { type: "text", text: "describe this" }),
+ storedChunk(2, "user", { type: "image", url: PNG, mimeType: "image/png" }),
+ storedChunk(3, "user", {
+ type: "text",
+ text: "[Image analysis (via kimi/k2)]: a red square",
+ }),
+ storedChunk(4, "assistant", { type: "text", text: "the square is red" }),
+ ]);
+ const msgs = selectMessages(s);
+ expect(msgs).toHaveLength(2); // one user message (3 chunks) + one assistant
+ expect(msgs[0]?.role).toBe("user");
+ expect(msgs[0]?.chunks).toHaveLength(3);
+ expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" });
+ });
});
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index d3b999d..64e41b9 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -1,26 +1,103 @@
-import type { AgentEvent, Chunk, StoredChunk } from "@dispatch/wire";
+import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire";
+import { assertChunkExhaustive } from "../wire/conformance";
import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types";
/** The initial empty transcript state. */
export function initialState(): TranscriptState {
- return {
- committed: [],
- provisional: [],
- accumulating: null,
- currentTurnId: null,
- latestUsage: null,
- sealedTurnId: null,
- };
+ return {
+ committed: [],
+ provisional: [],
+ accumulating: null,
+ currentTurnId: null,
+ latestUsage: null,
+ sealedTurnId: null,
+ hiddenBeforeSeq: 0,
+ hiddenThinkingCount: 0,
+ generating: false,
+ providerRetry: null,
+ };
+}
+
+/**
+ * Clear the `generating` flag without touching anything else. Used on a WS
+ * (re)connect: a turn may have sealed while we were disconnected, so the live
+ * `turn-sealed`/`done` that would have cleared `generating` was missed. The
+ * caller resets here, then re-subscribes — if the turn is still running the
+ * 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 };
}
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 }];
+}
+
+/**
+ * Content equality for two chunks of the SAME role (used to de-dup an
+ * optimistic echo against the authoritative committed version). Compares the
+ * discriminating payload only — `stepId` (generation provenance) is ignored
+ * since it is absent on provisional echoes but present on committed tool chunks.
+ */
+function chunkContentEquals(a: Chunk, b: Chunk): boolean {
+ if (a.type !== b.type) return false;
+ switch (a.type) {
+ case "text": {
+ const o = b as Extract<Chunk, { type: "text" }>;
+ return a.text === o.text;
+ }
+ case "thinking": {
+ const o = b as Extract<Chunk, { type: "thinking" }>;
+ return a.text === o.text;
+ }
+ case "image": {
+ const o = b as Extract<Chunk, { type: "image" }>;
+ return a.url === o.url;
+ }
+ case "error": {
+ const o = b as Extract<Chunk, { type: "error" }>;
+ return a.message === o.message && a.code === o.code;
+ }
+ case "system": {
+ const o = b as Extract<Chunk, { type: "system" }>;
+ return a.text === o.text;
+ }
+ case "tool-call": {
+ const o = b as Extract<Chunk, { type: "tool-call" }>;
+ return a.toolCallId === o.toolCallId && a.toolName === o.toolName;
+ }
+ case "tool-result": {
+ const o = b as Extract<Chunk, { type: "tool-result" }>;
+ return a.toolCallId === o.toolCallId && a.toolName === o.toolName && a.isError === o.isError;
+ }
+ default:
+ return assertChunkExhaustive(a) === assertChunkExhaustive(b);
+ }
+}
+
+/**
+ * The trailing run of consecutive same-role provisional chunks at the end of
+ * `provisional` (the optimistic echo of one message). Returns the start/end
+ * indices `[start, end)` into `provisional` (empty if none).
+ */
+function trailingRun(
+ provisional: readonly ProvisionalChunk[],
+ role: Role,
): 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 }];
+ const end = provisional.length;
+ let start = end;
+ while (start > 0 && provisional[start - 1]?.role === role) start--;
+ return provisional.slice(start, end);
}
/**
@@ -28,157 +105,342 @@ function flushAccumulating(
* Dedupes by seq (new wins), keeps seq-monotonic order, idempotent.
* When sealedTurnId is set, drops all provisional chunks (now superseded)
* and clears sealedTurnId.
+ *
+ * Chunks below the chat-limit unload watermark (`hiddenBeforeSeq`) are
+ * REJECTED: a full-cache or tail merge must not resurrect what the trim
+ * 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);
- for (const c of chunks) 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,
- };
- }
-
- return { ...state, committed };
+ 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,
+ };
+ }
+
+ // During generation: if new committed chunks arrived, the provisional
+ // array may contain duplicates — the optimistic echo from `appendUserMessage`
+ // is now backed by committed chunks (CR-6: user message persisted at turn
+ // start). A user message may be multi-chunk (`[text, image, image, …]`), so
+ // match the trailing provisional user-run against the trailing committed
+ // user-run by content equality and drop the whole echo when it is fully
+ // backed. Leaves the accumulating (streaming) chunk untouched.
+ if (addedNew && state.generating && state.provisional.length > 0) {
+ const provRun = trailingRun(state.provisional, "user");
+ if (provRun.length > 0) {
+ const commRun = trailingRun(committed, "user");
+ // Drop the provisional user echo iff every echoed chunk is content-equal
+ // to the corresponding committed chunk (the server persisted the same
+ // message). A partial match (echo longer than committed) keeps the echo
+ // — the not-yet-committed tail stays until the turn seals.
+ const fullyBacked =
+ commRun.length >= provRun.length &&
+ provRun.every((p, i) => {
+ const c = commRun[i];
+ return c !== undefined && chunkContentEquals(p.chunk, c.chunk);
+ });
+ if (fullyBacked) {
+ const dropStart = state.provisional.length - provRun.length;
+ const provisional = state.provisional.slice(0, dropStart);
+ return { ...state, committed, provisional, accumulating: state.accumulating };
+ }
+ }
+ }
+
+ return { ...state, committed };
}
/**
* Fold one live AgentEvent into the provisional state.
*
* - `turn-start` records the turnId.
+ * - `user-message` appends the turn's user prompt (de-duped vs the sender's
+ * optimistic echo) so a watcher renders it mid-turn.
* - `text-delta` extends the current accumulating TextChunk (or starts one).
* - `reasoning-delta` extends the current accumulating ThinkingChunk (or starts one).
* - `tool-call` / `tool-result` / `error` finalize any accumulating chunk and
* add a new provisional chunk.
+ * - `steering` appends a user bubble mid-turn (drained from the message queue
+ * at a tool-result boundary; the queue surface separately clears on drain).
* - `usage` stores the latest Usage.
* - `done` finalizes any accumulating chunk (turn still provisional).
* - `turn-sealed` finalizes any accumulating chunk and sets sealedTurnId.
* - `status` and `tool-output` are ignored (best-effort no-ops).
+ *
+ * `generating` is folded structurally: a `turn-start` or any content delta sets
+ * it true; `done` / `turn-sealed` / `error` clear it. This is what a watching
+ * (or reconnected) client renders as "generating…", with no dependence on the
+ * free-form `status` event string.
+ *
+ * NOTE: this is the inner reducer. The transient `provider-retry` banner is SET
+ * here (on a `provider-retry` event) but CLEARED by the `foldEvent` wrapper
+ * 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;
+
+ 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 USER run already contains an
+ // identical user text chunk. The echo may be multi-chunk (`[text, image,
+ // image, …]` — `appendUserMessage` appends the text first, then images), so
+ // we scan the whole trailing user run, not just the last chunk (which would
+ // be an image when images were pasted). A pure watcher has no such echo →
+ // it appends and renders. The `user-message` event carries ONLY text (never
+ // images — images arrive via history/loadSince); an images-only send (empty
+ // text) emits no `user-message` and is not de-duped here.
+ if (event.text.length === 0) return state;
+ const run = trailingRun(state.provisional, "user");
+ const alreadyEchoed = run.some((p) => p.chunk.type === "text" && p.chunk.text === event.text);
+ if (alreadyEchoed) {
+ 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 "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-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 "usage":
+ return { ...state, latestUsage: event.usage };
+
+ 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 "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 "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 };
+ }
+ }
+}
+
+/**
+ * Fold one live AgentEvent into the transcript state. Wraps `reduceEvent` to
+ * centralize the TRANSIENT `provider-retry` banner's clearing: the banner is
+ * SET by `reduceEvent` on a `provider-retry` event (coalescing), and CLEARED
+ * here when the model's content resumes (the retry succeeded) or the turn ends
+ * (done/sealed/error) or a new turn starts. Metadata/no-op events
+ * (`status`/`tool-output`/`usage`/`step-complete`) leave a showing banner
+ * untouched. The `state.providerRetry !== null` guard keeps the common path
+ * (no banner pending) identity-stable — no needless new object.
+ */
+// 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",
+]);
+
export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState {
- switch (event.type) {
- case "status":
- case "tool-output":
- return state;
-
- case "turn-start":
- return { ...state, currentTurnId: event.turnId };
-
- case "text-delta": {
- const acc = state.accumulating;
- if (acc !== null && acc.kind === "text") {
- return { ...state, accumulating: { kind: "text", text: acc.text + event.delta } };
- }
- const provisional = flushAccumulating(state.provisional, acc);
- return {
- ...state,
- provisional,
- accumulating: { kind: "text", text: event.delta },
- };
- }
-
- case "reasoning-delta": {
- const acc = state.accumulating;
- if (acc !== null && acc.kind === "thinking") {
- return { ...state, accumulating: { kind: "thinking", text: acc.text + event.delta } };
- }
- const provisional = flushAccumulating(state.provisional, acc);
- return {
- ...state,
- provisional,
- accumulating: { kind: "thinking", text: event.delta },
- };
- }
-
- 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,
- };
- return {
- ...state,
- provisional: [...provisional, { role: "assistant", chunk }],
- accumulating: null,
- };
- }
-
- 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,
- };
- return {
- ...state,
- provisional: [...provisional, { role: "tool", chunk }],
- accumulating: null,
- };
- }
-
- 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,
- };
- }
-
- case "usage":
- return { ...state, latestUsage: event.usage };
-
- case "done": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- return {
- ...state,
- provisional,
- accumulating: null,
- };
- }
-
- case "turn-sealed": {
- const provisional = flushAccumulating(state.provisional, state.accumulating);
- return {
- ...state,
- provisional,
- accumulating: null,
- sealedTurnId: event.turnId,
- };
- }
- }
+ 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;
}
/**
* Optimistically append a user message to the provisional list.
* Flushes any in-progress accumulating chunk first (defensively).
- * The provisional user chunk is superseded when applyHistory receives
+ * The provisional user chunks are superseded when applyHistory receives
* the authoritative committed chunks after a turn seals.
+ *
+ * When `images` are provided, they are appended AFTER the text chunk (in
+ * order) as `image` chunks — matching the server's persisted layout
+ * (`[text, image, image, …]` in one user message). A text chunk is only
+ * appended when `text` is non-empty (an images-only send echoes just the
+ * images). The `user-message` event carries only text (never images), so its
+ * de-dup scans the trailing user run for the echoed text rather than just the
+ * last chunk.
*/
-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,
- };
+export function appendUserMessage(
+ state: TranscriptState,
+ text: string,
+ images?: readonly ImageInput[],
+): TranscriptState {
+ const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const userChunks: Chunk[] = [];
+ if (text.length > 0) userChunks.push({ type: "text", text });
+ if (images !== undefined) {
+ for (const img of images) {
+ if (img.url.length === 0) continue;
+ const chunk: Chunk =
+ img.mimeType !== undefined
+ ? { type: "image", url: img.url, mimeType: img.mimeType }
+ : { type: "image", url: img.url };
+ userChunks.push(chunk);
+ }
+ }
+ if (userChunks.length === 0) {
+ // Nothing to echo (empty text + no images) — leave state unchanged but
+ // still flush any accumulating chunk defensively.
+ return { ...state, provisional, accumulating: null };
+ }
+ return {
+ ...state,
+ provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)],
+ accumulating: null,
+ };
}
diff --git a/src/core/chunks/retry-banner.test.ts b/src/core/chunks/retry-banner.test.ts
new file mode 100644
index 0000000..1d8c1cd
--- /dev/null
+++ b/src/core/chunks/retry-banner.test.ts
@@ -0,0 +1,63 @@
+import type { TurnProviderRetryEvent } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { formatRetryDelay, viewProviderRetry } from "./retry-banner";
+
+const retry = (
+ 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 };
+
+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
+ });
+});
+
+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();
+ });
+});
diff --git a/src/core/chunks/retry-banner.ts b/src/core/chunks/retry-banner.ts
new file mode 100644
index 0000000..afa6a98
--- /dev/null
+++ b/src/core/chunks/retry-banner.ts
@@ -0,0 +1,51 @@
+import type { TurnProviderRetryEvent } from "@dispatch/wire";
+
+/**
+ * Pure view-model for the transient `provider-retry` warning banner. Zero DOM,
+ * zero effects, zero Svelte — mirrors the `core/metrics` view-models the chat UI
+ * already imports. The banner state itself lives in `TranscriptState.providerRetry`
+ * (set/cleared by `foldEvent`); this module only formats an event into render data.
+ *
+ * The "countdown" is a STATIC label derived from `delayMs` (e.g. "retrying in
+ * 5s…"), matching the backend's examples — NOT a live ticking timer (which would
+ * be a component effect and re-render churn). Each new `provider-retry` event
+ * coalesces over the previous, so the banner always shows the newest attempt + delay.
+ */
+
+/** 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;
+}
+
+/**
+ * Format a millisecond delay as a short, human-friendly duration. Matches the
+ * backend's backoff schedule (5s→10s→30s→60s→5m→10m→15m→30m): under a minute
+ * 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`;
+}
+
+/**
+ * Map a `provider-retry` event to its banner view. `attempt` is 0-based (the Nth
+ * 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,
+ };
+}
diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts
index 8fb832f..e46d5f5 100644
--- a/src/core/chunks/selectors.ts
+++ b/src/core/chunks/selectors.ts
@@ -1,4 +1,4 @@
-import type { ChatMessage, Chunk } from "@dispatch/wire";
+import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "./types";
/**
@@ -6,46 +6,64 @@ 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 });
- }
- 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;
+}
+
+/**
+ * Whether a turn is currently generating (for a "generating…" indicator). True
+ * for ANY client watching the conversation — the sender, a second device, or a
+ * reconnected client whose in-flight turn was replayed.
+ */
+export function selectGenerating(state: TranscriptState): boolean {
+ return state.generating;
+}
+
+/**
+ * The latest `provider-retry` event for the current turn, or `null` when no retry
+ * is pending. Drives the transient yellow "retrying…" warning banner (rendered
+ * by ChatView). Never persisted — see `TranscriptState.providerRetry`.
+ */
+export function selectProviderRetry(state: TranscriptState): TurnProviderRetryEvent | null {
+ 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
new file mode 100644
index 0000000..7c4bbef
--- /dev/null
+++ b/src/core/chunks/trim.test.ts
@@ -0,0 +1,235 @@
+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,
+} 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}` } };
+}
+
+function chunks(from: number, to: number): StoredChunk[] {
+ 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 };
+}
+
+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);
+ });
+});
+
+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
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts
new file mode 100644
index 0000000..9846357
--- /dev/null
+++ b/src/core/chunks/trim.ts
@@ -0,0 +1,185 @@
+// Chat-limit windowing for the transcript — PURE policy, zero DOM/Svelte.
+//
+// In very long conversations an unbounded transcript makes the browser crawl, so
+// the FE keeps at most `chat limit` chunks loaded and UNLOADS the oldest ones in
+// BULK: a quarter of the limit at a time (limit 100 → at 101 chunks it unloads 25,
+// leaving 76). Bulk-on-threshold — NOT one-per-delta like old Dispatch — so a trim
+// happens once per ~quarter-limit of new content instead of on every step, which
+// was the old scroll-jump-per-step failure mode. A fresh page load shows only the
+// newest `floor(0.75 × limit)` chunks, leaving headroom before the first trim.
+//
+// Unloading drops COMMITTED chunks only (provisional chunks are the in-flight
+// turn; they become committed at seal and trimmable then) and records the
+// `hiddenBeforeSeq` watermark so history merges can't resurrect them and the
+// "Show earlier messages" affordance knows where to page back in from.
+
+import type { StoredChunk } from "@dispatch/wire";
+import type { TranscriptState } from "./types";
+
+/** Default chat limit (max loaded chunks per conversation). */
+export const DEFAULT_CHAT_LIMIT = 256;
+/** Hard floor for a configured chat limit (a tiny window would thrash). */
+export const MIN_CHAT_LIMIT = 10;
+/** Hard ceiling for a configured chat limit. */
+export const MAX_CHAT_LIMIT = 100_000;
+
+/**
+ * Normalize an untrusted configured limit (e.g. parsed from localStorage):
+ * non-numeric/NaN → the default; otherwise floored + clamped to
+ * [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;
+}
+
+/** The bulk-unload unit: a quarter of the limit, rounded up. */
+export function unloadCount(limit: number): number {
+ 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));
+}
+
+/** 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);
+}
+
+function countThinking(chunks: readonly StoredChunk[]): number {
+ 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),
+ };
+}
+
+/**
+ * Enforce the chat limit: when the loaded count EXCEEDS `limit`, unload whole
+ * quarters (`unloadCount(limit)` each) of the OLDEST committed chunks until back
+ * at/under the limit — normally exactly one quarter (limit 100: 101 → 76); more
+ * only when trimming was deferred (e.g. while the reader was scrolled up).
+ * At/under the limit this is the identity. When committed chunks are
+ * exhausted, also drops the oldest provisional chunks (the in-flight turn)
+ * 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;
+}
+
+/**
+ * Window the committed history down to the newest `maxCommitted` chunks (the
+ * fresh-load path: `maxCommitted = initialWindowSize(limit)`). Identity when
+ * 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);
+}
+
+/**
+ * The oldest LOADED seq — the start of the transcript's loaded window. Usually
+ * `committed[0].seq`; falls back to the watermark when a trim emptied the
+ * committed list (all-provisional overflow). 0 = window start unknown/origin.
+ */
+function oldestLoadedSeq(state: TranscriptState): number {
+ return state.committed[0]?.seq ?? state.hiddenBeforeSeq;
+}
+
+/**
+ * Page earlier history back in — the "Show earlier messages" action.
+ *
+ * `earlier` is every locally-known chunk older than the loaded window
+ * (typically the full cached conversation, possibly extended by a CR-5
+ * `?beforeSeq=` backfill; chunks at/inside the window are ignored). The newest
+ * `count` of them are merged back in front of `committed`, and the watermark
+ * follows the new window start so history merges still can't resurrect what
+ * remains unloaded. Identity when the window already starts at seq 1 (the
+ * contractual origin) or nothing older is known locally.
+ */
+export function restoreEarlier(
+ 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)),
+ };
+}
+
+/**
+ * Whether earlier history exists below the loaded window — drives the
+ * "Show earlier messages" affordance. Derived from the [email protected] CONTRACT
+ * that per-conversation seqs are 1-based and gap-free: a loaded window that
+ * starts above seq 1 means older chunks exist (locally cached or server-side),
+ * whether the window came from a local trim or a server-windowed (`?limit=`)
+ * fresh load.
+ */
+export function selectHasEarlier(state: TranscriptState): boolean {
+ return oldestLoadedSeq(state) > 1;
+}
diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts
index 3792445..2ced736 100644
--- a/src/core/chunks/types.ts
+++ b/src/core/chunks/types.ts
@@ -1,31 +1,77 @@
-import type { Chunk, Role, StoredChunk, Usage } from "@dispatch/wire";
+import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@dispatch/wire";
/** 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;
+ 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;
+ 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
new file mode 100644
index 0000000..97170d0
--- /dev/null
+++ b/src/core/metrics/format.test.ts
@@ -0,0 +1,375 @@
+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,
+} from "./format";
+
+describe("computeTps", () => {
+ 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 negative", () => {
+ expect(computeTps(100, -100)).toBeNull();
+ });
+
+ it("computes tokens per second", () => {
+ expect(computeTps(1000, 2000)).toBe(500);
+ });
+
+ 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");
+ });
+});
+
+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");
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
+
+describe("formatContextSize", () => {
+ 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 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");
+ });
+});
+
+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 null (never 0)", () => {
+ const u = computeContextUsage(undefined, 1_000_000);
+ expect(u.current).toBeNull();
+ expect(u.percent).toBeNull();
+ });
+
+ it("an explicit 0 context size is a real reported value (current 0)", () => {
+ const u = computeContextUsage(0, 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
new file mode 100644
index 0000000..894bd54
--- /dev/null
+++ b/src/core/metrics/format.ts
@@ -0,0 +1,179 @@
+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");
+}
+
+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`;
+}
+
+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`;
+}
+
+/**
+ * Format the current context size for display. A defined count renders as
+ * `"<n> tokens in context"` (thousands-separated); `undefined` ("unknown" — no
+ * per-step usage reported yet) renders the placeholder `"context size unknown"`.
+ * 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`;
+}
+
+/**
+ * Compact token count for a slim status bar: `812`, `12.3k`, `1.2M`. Full
+ * 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`;
+}
+
+/**
+ * Context-window occupancy: the current size against a max window limit.
+ *
+ * `current` is the latest turn's context size, or `null` when unknown (no
+ * per-step usage reported yet) — NEVER coerced to `0`, so a consumer cannot
+ * silently render "0 tokens / 1M"; it must branch on `current === null` and show
+ * a placeholder instead. `max` is the model's window limit (or `null` when
+ * unknown). `percent` is `current / max * 100` clamped to [0, 100], UNROUNDED
+ * (the UI picks the precision) — so a few-thousand-token context against a
+ * 1,000,000 window still reads non-zero. `percent` is `null` when `current` OR
+ * `max` is unknown (no bar/denominator).
+ */
+export interface ContextUsage {
+ readonly current: number | null;
+ readonly max: number | null;
+ readonly percent: number | null;
+}
+
+export function computeContextUsage(
+ contextSize: number | undefined,
+ contextLimit: number | null | undefined,
+): ContextUsage {
+ const current = contextSize ?? null;
+ const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null;
+ const percent =
+ current === null || 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);
+}
+
+function totalTokens(u: Usage): number {
+ 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;
+}
+
+/** 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),
+ };
+}
+
+/**
+ * Cache hit rate as a 0..100 integer percentage: `cacheReadTokens / inputTokens`,
+ * clamped to [0,1]. Absent cache field counts as 0; a 0% rate is legitimate (not
+ * 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);
+}
+
+/** 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";
+}
+
+/** 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 };
+}
+
+/**
+ * Expected cache (retention): of the cache that existed going INTO this turn, how
+ * much was read back — `clamp01(cacheRead_N / (cacheRead_{N-1} + cacheWrite_{N-1}))`.
+ * The denominator is the PRIOR turn's cached prefix (what it read + what it wrote).
+ * Ideally ~100% on every turn after the first; <100% = the cache busted/expired.
+ *
+ * Returns `null` when it cannot be derived: no prior turn (`prev === null`) or the
+ * 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);
+}
+
+/**
+ * Build a view of the cross-turn retention (percentage + colour level + hit flag),
+ * 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 };
+}
+
+/** 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),
+ };
+}
diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts
new file mode 100644
index 0000000..d3c9669
--- /dev/null
+++ b/src/core/metrics/index.ts
@@ -0,0 +1,31 @@
+export {
+ type ContextUsage,
+ computeCachePct,
+ computeContextUsage,
+ computeExpectedCachePct,
+ computeTps,
+ formatCompactTokens,
+ formatContextSize,
+ viewCacheRate,
+ viewExpectedCache,
+ viewStepMetrics,
+ viewTurnMetrics,
+} from "./format";
+export { interleaveTurnMetrics } from "./place";
+export {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectCurrentContextSize,
+ selectOrderedTurnMetrics,
+} from "./reducer";
+export type {
+ 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
new file mode 100644
index 0000000..9c925a3
--- /dev/null
+++ b/src/core/metrics/place.test.ts
@@ -0,0 +1,621 @@
+import type { StepId, StepMetrics, TurnMetrics } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import type { RenderGroup } from "../chunks";
+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,
+ },
+ };
+}
+
+function assistantGroup(seq: number, text: string): RenderGroup {
+ 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,
+ },
+ };
+}
+
+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,
+ },
+ };
+}
+
+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,
+ };
+}
+
+function makeStep(stepId: string, inputTokens: number, outputTokens: number): StepMetrics {
+ return {
+ stepId: stepId as StepId,
+ usage: { inputTokens, outputTokens },
+ };
+}
+
+function makeTurn(
+ turnId: string,
+ inputTokens: number,
+ outputTokens: number,
+ steps: StepMetrics[] = [],
+): TurnMetrics {
+ return {
+ turnId,
+ usage: { inputTokens, outputTokens },
+ steps,
+ };
+}
+
+function makeEntry(
+ turnId: string,
+ inputTokens: number,
+ outputTokens: number,
+ steps: StepMetrics[] = [],
+): TurnMetricsEntry {
+ return {
+ turnId,
+ steps,
+ total: makeTurn(turnId, inputTokens, outputTokens, steps),
+ };
+}
+
+function makeProgressiveEntry(turnId: string, steps: StepMetrics[]): TurnMetricsEntry {
+ return {
+ turnId,
+ steps,
+ total: null,
+ };
+}
+
+function expectGroupAt(
+ 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);
+}
+
+function expectStepMetricsAt(
+ 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);
+}
+
+function expectTurnMetricsAt(
+ 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);
+}
+
+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("trimmed leading turns: a mixed tool+text transcript tail-aligns text-only turns to their OWN (newest) entries, not stale trimmed ones", () => {
+ // A long conversation where the chat limit unloaded the oldest turn (t1).
+ // Metrics still hold all three turns; the loaded transcript is turns 2-3.
+ // Turn 2 is a tool turn (matched by stepId); turn 3 is text-only (no
+ // stepId groups) — the failure case. The text-only turn MUST get its OWN
+ // entry (t3), NOT the trimmed t1's stale metrics.
+ const g3 = userGroup(3, "q2");
+ const g4 = toolBatchGroup("s2", ["c2"]);
+ const g5 = assistantGroup(4, "tool-reply");
+ const g6 = userGroup(5, "q3");
+ const g7 = assistantGroup(6, "text-reply");
+ const step1 = makeStep("s1", 11, 1); // t1 (trimmed)
+ const step2 = makeStep("s2", 22, 2); // t2 (loaded, tool)
+ const step3 = makeStep("s3", 33, 3); // t3 (loaded, text-only — unanchored)
+ const entries = [
+ makeEntry("t1", 11, 1, [step1]),
+ makeEntry("t2", 22, 2, [step2]),
+ makeEntry("t3", 33, 3, [step3]),
+ ];
+ const rows = interleaveTurnMetrics([g3, g4, g5, g6, g7], entries);
+
+ const tmRows = rows.filter(
+ (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics",
+ );
+ // Two loaded turns → two turn-metrics rows. The trimmed t1 does NOT render.
+ expect(tmRows).toHaveLength(2);
+ // CRITICAL: the text-only turn (segment 1) got t3 (its own newest entry),
+ // not t1 (the stale trimmed one). A misaligned head-align would show t1.
+ expect(tmRows[1]?.turn.turnId).toBe("t3");
+ expect(tmRows[0]?.turn.turnId).toBe("t2");
+ // And t1 never appears as a rendered row.
+ expect(tmRows.some((r) => r.turn.turnId === "t1")).toBe(false);
+ });
+
+ it("trimmed turn still counts toward the cumulative 'chat total' on the first visible turn", () => {
+ // t1 is trimmed (no segment) but finalized; t2 is the loaded visible turn.
+ // t2's "Chat Total" cumulative must INCLUDE t1's usage (the whole chat),
+ // even though t1 renders no row of its own.
+ const g1 = userGroup(2, "q2");
+ const g2 = assistantGroup(3, "a2");
+ const entries = [
+ {
+ turnId: "t1",
+ steps: [],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 },
+ steps: [],
+ },
+ },
+ {
+ turnId: "t2",
+ steps: [],
+ total: {
+ turnId: "t2",
+ usage: { inputTokens: 2000, outputTokens: 20, cacheReadTokens: 1600 },
+ steps: [],
+ },
+ },
+ ];
+ const rows = interleaveTurnMetrics([g1, g2], entries);
+ const tmRows = rows.filter(
+ (r): r is Extract<MetricsRow, { kind: "turn-metrics" }> => r.kind === "turn-metrics",
+ );
+ // Only the loaded turn renders a row; the trimmed t1 does not.
+ expect(tmRows).toHaveLength(1);
+ expect(tmRows[0]?.turn.turnId).toBe("t2");
+ // Cumulative includes BOTH turns (t1 + t2): input 3000, cacheRead 2100.
+ expect(tmRows[0]?.cumulativeUsage.inputTokens).toBe(3000);
+ expect(tmRows[0]?.cumulativeUsage.cacheReadTokens).toBe(2100);
+ // Retention baseline is the prior finalized turn (t1, even though trimmed).
+ expect(tmRows[0]?.prevTurnUsage?.inputTokens).toBe(1000);
+ expect(tmRows[0]?.prevTurnUsage?.cacheReadTokens).toBe(500);
+ });
+
+ 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("trimmed turn (more metrics than segments) does NOT emit a standalone row at the top", () => {
+ 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])],
+ );
+
+ // t2's content was unloaded by the chat limit (no segment for it); its
+ // metrics must NOT render a standalone row piled at the top. Only the
+ // loaded turn's content + its matched metrics appear. (t2 still counts
+ // toward the cumulative "chat total" — see the cache-total tests.)
+ expect(rows).toHaveLength(4);
+ expectGroupAt(rows, 0, g1);
+ expectGroupAt(rows, 1, g2);
+ expectStepMetricsAt(rows, 2, "s1", 0);
+ expectTurnMetricsAt(rows, 3, "t1");
+ // No standalone turn-metrics row for t2 anywhere.
+ const tmRows = rows.filter((r) => r.kind === "turn-metrics");
+ expect(tmRows).toHaveLength(1);
+ expect((tmRows[0] as { readonly turn: TurnMetrics }).turn.turnId).toBe("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);
+ });
+});
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
new file mode 100644
index 0000000..7122b09
--- /dev/null
+++ b/src/core/metrics/place.ts
@@ -0,0 +1,298 @@
+import type { Usage } from "@dispatch/wire";
+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;
+}
+
+/** 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;
+}
+
+/**
+ * Interleave turn metrics into the rendered transcript.
+ *
+ * Splits groups into per-turn segments: a new segment begins at each `single`
+ * group with `group.chunk.role === "user"`. Segments are matched to entries
+ * by `stepId` presence when possible (robust against chat-limit trimming: when
+ * a turn's user message is trimmed, positional alignment would be off, but
+ * stepId matching still finds the right entry). Segments with no stepId-bearing
+ * groups (text-only turns) fall back to POSITIONAL tail-alignment: since the
+ * loaded transcript is always a SUFFIX of the full turn history (the chat limit
+ * keeps the newest and unloads the oldest), segment `seg` ↔ entry `K - T + seg`.
+ *
+ * Within a segment that has a matched entry, each completed step's metrics
+ * are placed INLINE right after the last group bearing that step's `stepId`.
+ * Steps whose `stepId` does not appear in any group ("unanchored"):
+ * - If the segment HAS stepId-bearing groups (tool chunks exist but this step's
+ * were trimmed): SKIPPED (no blank "step N · 0 tok" bubbles).
+ * - If the segment has NO stepId-bearing groups (text-only turn): placed at the
+ * segment tail before the turn-metrics row (the original behavior).
+ *
+ * A `turn-metrics` row is emitted ONLY when `entry.total !== null` (i.e. the turn
+ * is finalized via `done` or durable data). A still-generating turn emits no
+ * turn-total row.
+ *
+ * Fully trimmed turns (entries whose content was unloaded by the chat limit and
+ * which match no segment) are NOT rendered as standalone rows — that previously
+ * piled a wall of stale cache badges at the top of a long, trimmed transcript.
+ * Their usage still counts toward the per-turn "chat total" cumulative (computed
+ * across ALL finalized turns in entry-array order), so the running cache rate
+ * stays correct regardless of which turns were trimmed; paging earlier history
+ * back in ("Show earlier messages") re-matches them and re-renders their rows.
+ */
+export function interleaveTurnMetrics(
+ groups: readonly RenderGroup[],
+ entries: readonly TurnMetricsEntry[],
+): readonly MetricsRow[] {
+ 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);
+ }
+ }
+
+ 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;
+ }
+
+ if (T === 0) {
+ return groups.map((g) => ({ kind: "group" as const, group: g }));
+ }
+
+ 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)));
+
+ // Match segments to entries. Pass 1: match by stepId overlap (handles
+ // trimming where positional alignment alone could be ambiguous). Pass 2:
+ // positional tail-alignment 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;
+
+ 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);
+ }
+ }
+ }
+
+ // Pass 2: positional fallback for segments pass 1 left unmatched
+ // (text-only turns with no stepId-bearing groups to anchor on).
+ //
+ // The loaded transcript is always a SUFFIX of the full turn history —
+ // chat-limit/windowing keeps the NEWEST chunks and unloads the OLDEST — so
+ // the T loaded segments correspond to the LAST T entries. TAIL-ALIGNMENT
+ // (segment `seg` ↔ entry `K - T + seg`) is therefore correct whenever the
+ // metrics hold at least as many turns as there are loaded segments
+ // (`K >= T`): the leading `K - T` entries are TRIMMED turns (their content
+ // was unloaded) and must be skipped, never matched to a newer segment.
+ //
+ // This MUST run even when pass 1 matched SOME segments (tool turns). The
+ // earlier code only tail-aligned when pass 1 matched NONE, falling back to
+ // HEAD-alignment otherwise — which, with leading trimmed entries, matched a
+ // brand-new text-only turn to an old (trimmed) entry's STALE metrics (the
+ // "new steps show no / wrong cache" failure). Tail-aligning by position is
+ // safe alongside pass 1: stepIds are unique per turn, so pass 1 already
+ // grabbed each tool turn's positionally-correct entry, leaving the right
+ // entry free for each text-only turn.
+ //
+ // Only when `K < T` (fewer entries than segments — some loaded turns have no
+ // metrics yet, e.g. a metrics sync still pending or a freshly loaded
+ // transcript) do we head-align, assigning the first K entries to the first K
+ // unmatched segments (the turns that DO have metrics sit at the front).
+ if (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 >= 0 && 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 (K < T): first K entries to first K 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);
+ }
+
+ const rows: MetricsRow[] = [];
+
+ const firstUserIdx = segmentStarts[0] ?? 0;
+
+ 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;
+
+ 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;
+ }
+
+ 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);
+ }
+ }
+
+ // 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.
+ }
+
+ // 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,
+ });
+ }
+ }
+
+ return rows;
+}
diff --git a/src/core/metrics/reducer.test.ts b/src/core/metrics/reducer.test.ts
new file mode 100644
index 0000000..581a8b7
--- /dev/null
+++ b/src/core/metrics/reducer.test.ts
@@ -0,0 +1,689 @@
+import type { StepId, TurnDoneEvent, TurnStepCompleteEvent, TurnUsageEvent } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectCurrentContextSize,
+ selectOrderedTurnMetrics,
+} from "./reducer";
+
+const usageEvent = (
+ 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 stepCompleteEvent = (
+ turnId: string,
+ stepId: string,
+ timing: { ttftMs?: number; decodeMs?: number; genTotalMs?: number } = {},
+): TurnStepCompleteEvent => ({
+ 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;
+ } = {},
+): TurnDoneEvent => ({
+ 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([]);
+ });
+});
+
+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");
+ });
+});
+
+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);
+ });
+});
+
+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);
+ });
+});
+
+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("in-flight turn updates context size after the first step completes", () => {
+ // Before the requirement: an in-flight turn had total=null so its step usage
+ // was ignored until `done`. Now the latest step's input+output is used.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+
+ // Still generating (no done) — context = step 1 input+output = 5200.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("in-flight turn updates progressively as each step reports usage", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Step 2 reports usage mid-stream (before its step-complete): each step's
+ // input already includes all prior context, so the last step's input+output
+ // is the current occupancy.
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size is the latest step with usage, NOT the aggregate sum", () => {
+ // Mirrors the finalized-turn test: contextSize is the FINAL step's
+ // input+output, not the sum across steps (which would overcount a
+ // multi-step turn because every step re-prefills the growing prompt).
+ 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"));
+ // Aggregate would be 300+130=430; the latest step is 200+80=280.
+ expect(selectCurrentContextSize(s)).toBe(280);
+ });
+
+ it("in-flight turn with a step-complete but no usage falls back to older turn", () => {
+ // step-complete before usage → the step has no usage yet, so the in-flight
+ // turn exposes no context size and the display falls back to the prior
+ // finalized turn's value (never 0).
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1", { genTotalMs: 500 }));
+
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight turn with no steps/usage returns undefined (falls back)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 just started — no usage, no complete step — omitted entirely.
+ s = foldMetricsEvent(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ expect(selectCurrentContextSize(s)).toBe(700);
+
+ // t2's first step reports usage → the display jumps to t2's live value.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("done finalizes the in-flight progressive value with the authoritative contextSize", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // done stamps the authoritative contextSize (the final step's input+output).
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 5350 }));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("in-flight context size excludes cache tokens (they are a subset of inputTokens)", () => {
+ // cacheReadTokens / cacheWriteTokens are portions of inputTokens already
+ // counted — adding them would double-count. Only input+output is occupancy.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s1" as StepId,
+ usage: {
+ inputTokens: 5000,
+ outputTokens: 200,
+ cacheReadTokens: 4000,
+ cacheWriteTokens: 1000,
+ },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // 5000+200=5200, NOT 9200 (with cacheRead) or 10200 (with both).
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+
+ it("multiple in-flight turns: the newest turn's live value wins", () => {
+ let s = initialMetricsState();
+ // t1 (older) in-flight with one completed step → 5200.
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // t2 (newer, seen later → last in liveOrder) in-flight → 8000.
+ s = foldMetricsEvent(s, usageEvent("t2", 7800, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(8000);
+ });
+
+ it("out-of-order step IDs: usage for step 2 before step 1's step-complete still scans newest-first", () => {
+ // stepOrder is FIRST-SEEN: s1 (its usage arrived first), then s2. So s2 is
+ // the newest step regardless of when each step's step-complete arrives.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, usageEvent("t1", 5200, 150, "s2"));
+ // Neither step complete yet → the turn is omitted (no complete step), so the
+ // display can't update until the first step completes.
+ expect(selectCurrentContextSize(s)).toBeUndefined();
+
+ // s1 completes AFTER s2's usage was reported. The turn is now visible; the
+ // newest-first scan picks s2 (the later step), not s1 (the just-completed one).
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+
+ // s2 completes — still s2, unchanged.
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ expect(selectCurrentContextSize(s)).toBe(5350);
+ });
+
+ it("done turn without contextSize falls back to an older turn (even with step usage)", () => {
+ // Contract lock-in: a done turn's step usage is NOT consulted for the
+ // context display — only its authoritative total.contextSize is. When that
+ // is absent, the display falls back to the next older finalized turn rather
+ // than synthesizing a value from the step usage.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 700 }));
+ // t2 done WITH step usage but NO done.contextSize (edge case: the done event
+ // omitted contextSize despite per-step usage).
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t2"));
+ expect(selectCurrentContextSize(s)).toBe(700);
+ });
+
+ it("in-flight context size skips a step with unsafe usage (NaN / negative)", () => {
+ // A corrupt provider report must never reach the status bar. The newest
+ // step with invalid counters is skipped, falling back to the prior valid one.
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 5000, 200, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ // s2 reports NaN input (e.g. a non-numeric provider field coerced).
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s2" as StepId,
+ usage: { inputTokens: Number.NaN, outputTokens: 150 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s2"));
+ // s2 skipped (NaN) → falls back to s1's 5200, NOT NaN.
+ expect(selectCurrentContextSize(s)).toBe(5200);
+
+ // Negative tokens are likewise skipped.
+ s = foldMetricsEvent(s, {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: "s3" as StepId,
+ usage: { inputTokens: -10, outputTokens: 5 },
+ });
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s3"));
+ expect(selectCurrentContextSize(s)).toBe(5200);
+ });
+});
+
+describe("applyDurableMetrics pruning", () => {
+ it("prunes a live turn once durable data covers it (no unbounded growth)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ expect(s.live.has("t1")).toBe(true);
+ expect(s.liveOrder).toContain("t1");
+
+ s = applyDurableMetrics(s, [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ steps: [{ stepId: "s1" as StepId, usage: { inputTokens: 100, outputTokens: 50 } }],
+ contextSize: 150,
+ },
+ ]);
+ // The live copy is gone; the durable (authoritative) entry replaces it.
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.liveOrder).not.toContain("t1");
+ expect(s.durable.has("t1")).toBe(true);
+ // The display still reads the durable value atomically (no gap).
+ expect(selectCurrentContextSize(s)).toBe(150);
+ });
+
+ it("prunes only the turns present in the durable batch (leaves other live turns)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t1", 100, 50, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t1", "s1"));
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 150 }));
+ // t2 still in flight — must NOT be pruned when only t1 seals.
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, steps: [], contextSize: 150 },
+ ]);
+ expect(s.live.has("t1")).toBe(false);
+ expect(s.live.has("t2")).toBe(true);
+ expect(s.liveOrder).toEqual(["t2"]);
+ // The newest (in-flight) turn's live value still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("is a no-op when no incoming turn is live (no live mutation)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, usageEvent("t2", 800, 10, "s1"));
+ s = foldMetricsEvent(s, stepCompleteEvent("t2", "s1"));
+ const before = s;
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [] },
+ ]);
+ // t1 was never live → the live map/order are unchanged (same reference).
+ expect(s.live).toBe(before.live);
+ expect(s.liveOrder).toBe(before.liveOrder);
+ // t1 (durable) is older; the in-flight t2 still wins.
+ expect(selectCurrentContextSize(s)).toBe(810);
+ });
+
+ it("durable wins over live for a shared turnId (pruned live no longer consulted)", () => {
+ let s = initialMetricsState();
+ s = foldMetricsEvent(s, doneEvent("t1", { contextSize: 111 }));
+ s = applyDurableMetrics(s, [
+ { turnId: "t1", usage: { inputTokens: 1, outputTokens: 1 }, steps: [], contextSize: 222 },
+ ]);
+ // The live (111) copy is pruned; only durable (222) remains.
+ expect(s.live.has("t1")).toBe(false);
+ expect(selectCurrentContextSize(s)).toBe(222);
+ });
+});
diff --git a/src/core/metrics/reducer.ts b/src/core/metrics/reducer.ts
new file mode 100644
index 0000000..39fc5ee
--- /dev/null
+++ b/src/core/metrics/reducer.ts
@@ -0,0 +1,345 @@
+import type { AgentEvent, StepId, StepMetrics, TurnMetrics, Usage } from "@dispatch/wire";
+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;
+
+ 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;
+}
+
+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;
+}
+
+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;
+}
+
+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;
+}
+
+/**
+ * A step's contribution to the live context size: `inputTokens + outputTokens`,
+ * or `undefined` when the step has no usage yet OR its counters are not safe to
+ * sum (non-finite / negative — defensive: a corrupt provider report must never
+ * reach the status bar as NaN/Infinity). Cache tokens are deliberately NOT
+ * included: `cacheReadTokens` / `cacheWriteTokens` are a SUBSET of
+ * `inputTokens`, so adding them would double-count.
+ */
+function stepContextSize(usage: Usage | undefined): number | undefined {
+ if (usage === undefined) return undefined;
+ const { inputTokens, outputTokens } = usage;
+ if (!Number.isFinite(inputTokens) || !Number.isFinite(outputTokens)) return undefined;
+ if (inputTokens < 0 || outputTokens < 0) return undefined;
+ return inputTokens + outputTokens;
+}
+
+/**
+ * The context size an IN-FLIGHT (not-done) turn occupies right now — for
+ * progressive display DURING a turn (before it seals), so the indicator updates
+ * after each step instead of waiting for `done`.
+ *
+ * CONTRACT: only call this on a turn whose `done` event has NOT arrived (the
+ * caller, `selectCurrentContextSize`, reaches it solely for entries with
+ * `total === null`, i.e. `lt.done === false`). Finalized turns use their
+ * authoritative `contextSize` instead; `doneContextSize` is read on the
+ * `total` path, never here.
+ *
+ * Returns the most recent step WITH USABLE USAGE's `inputTokens + outputTokens`
+ * (scanning newest → oldest by first-seen step order): each step's input
+ * already includes all prior context (the prompt is re-prefilled every step), so
+ * the last step's input+output is the true occupancy — the same definition
+ * `TurnDoneEvent.contextSize` stamps at turn end. A just-reported step's usage
+ * wins immediately, even mid-stream. Steps with no usage or unsafe usage are
+ * skipped, falling back to the next older usable step. `undefined` when no step
+ * has reported usable usage yet.
+ */
+function liveTurnContextSize(lt: LiveTurn): number | undefined {
+ for (let i = lt.stepOrder.length - 1; i >= 0; i--) {
+ const step = lt.stepMap.get(lt.stepOrder[i] ?? "");
+ const ctx = stepContextSize(step?.usage);
+ if (ctx !== undefined) return ctx;
+ }
+ return undefined;
+}
+
+function ensureLiveTurn(state: MetricsState, turnId: string): [MetricsState, LiveTurn] {
+ 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];
+}
+
+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 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: [],
+ };
+}
+
+/**
+ * Fold one live AgentEvent into the metrics state.
+ *
+ * - `usage` with `stepId`: upsert that step's usage.
+ * - `usage` without `stepId`: ignored.
+ * - `step-complete`: upsert that step's timing; default usage to zeros if absent.
+ * - `done`: set turn's `durationMs`, optional aggregate `usage`, and optional `contextSize`.
+ * - 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 };
+ }
+
+ 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 };
+ }
+
+ default:
+ return state;
+ }
+}
+
+/**
+ * Store durable (sealed) metrics from the backend. These win over live data
+ * for any shared `turnId`.
+ *
+ * Once durable (authoritative) data covers a turn, its live (in-memory) copy
+ * is REDUNDANT and is pruned from `state.live` / `liveOrder` so the live map
+ * doesn't grow unbounded over a long conversation. There is no display gap:
+ * the durable entry replaces the live one atomically in the same fold, and
+ * `selectOrderedTurnMetrics` / `selectCurrentContextSize` read durable for it.
+ */
+export function applyDurableMetrics(
+ state: MetricsState,
+ turns: readonly TurnMetrics[],
+): MetricsState {
+ const newDurable = new Map(state.durable);
+ const newDurableOrder = [...state.durableOrder];
+ const prunedIds = new Set<string>();
+ for (const turn of turns) {
+ if (!newDurable.has(turn.turnId)) {
+ newDurableOrder.push(turn.turnId);
+ }
+ newDurable.set(turn.turnId, turn);
+ if (state.live.has(turn.turnId)) prunedIds.add(turn.turnId);
+ }
+
+ if (prunedIds.size === 0) {
+ return { ...state, durable: newDurable, durableOrder: newDurableOrder };
+ }
+
+ const newLive = new Map(state.live);
+ for (const id of prunedIds) newLive.delete(id);
+ const newLiveOrder = state.liveOrder.filter((id) => !prunedIds.has(id));
+
+ return {
+ ...state,
+ live: newLive,
+ liveOrder: newLiveOrder,
+ durable: newDurable,
+ durableOrder: newDurableOrder,
+ };
+}
+
+/**
+ * Select the merged ordered list of turn metrics entries.
+ * Durable turns come first (in their order), then any live turns whose
+ * `turnId` is not in durable (in live first-seen order).
+ *
+ * Each entry contains the completed steps so far and an optional total
+ * (null until the turn is finalized via `done` or durable data).
+ * 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>();
+
+ 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;
+
+ 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;
+
+ result.push({
+ turnId,
+ steps: completeSteps,
+ total: lt.done ? liveTurnToMetrics(lt) : null,
+ });
+ }
+
+ return result;
+}
+
+/**
+ * Select the conversation's CURRENT context size — the tokens it occupies right
+ * now. Per the wire contract a client reads the LATEST turn's `contextSize`; we
+ * scan the merged ordered turns NEWEST → OLDEST and return the first DEFINED
+ * value.
+ *
+ * For a FINALIZED turn (`done` event or durable data) we use its authoritative
+ * `contextSize`. For an IN-FLIGHT (not-done) turn we compute it PROGRESSIVELY
+ * from the most recent step WITH USAGE — its `inputTokens + outputTokens` is the
+ * current occupancy (mirroring `TurnDoneEvent.contextSize`'s definition) — so
+ * the indicator updates after each step completes instead of waiting for the
+ * turn to seal. An in-flight turn with no step usage yet is skipped, falling
+ * back to the next older finalized turn.
+ *
+ * Returns `undefined` ("unknown") when no turn carries a context size — the
+ * caller renders a placeholder, NEVER `0`. Durable (sealed) data wins over
+ * 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 entry = ordered[i];
+ if (entry === undefined) continue;
+ if (entry.total !== null) {
+ if (entry.total.contextSize !== undefined) return entry.total.contextSize;
+ continue;
+ }
+ // In-flight turn: progressive context size from the latest step with usage.
+ const lt = state.live.get(entry.turnId);
+ if (lt !== undefined) {
+ const live = liveTurnContextSize(lt);
+ if (live !== undefined) return live;
+ }
+ }
+ return undefined;
+}
diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts
new file mode 100644
index 0000000..84d1904
--- /dev/null
+++ b/src/core/metrics/types.ts
@@ -0,0 +1,97 @@
+import type { StepMetrics, TurnMetrics, Usage } from "@dispatch/wire";
+import type { RenderGroup } from "../chunks";
+
+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;
+}
+
+/** 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[];
+}
+
+/**
+ * Reducer state for per-turn / per-step token + timing metrics.
+ *
+ * - `live`: in-flight turns keyed by `turnId` in FIRST-SEEN order.
+ * - `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[];
+}
+
+/** 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;
+}
+
+/** 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;
+ };
+
+/** 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;
+}
+
+/** 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;
+}
+
+/** 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;
+}
diff --git a/src/core/protocol/index.ts b/src/core/protocol/index.ts
index 25174ea..2c1c290 100644
--- a/src/core/protocol/index.ts
+++ b/src/core/protocol/index.ts
@@ -1,2 +1,9 @@
-export { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer";
-export type { ProtocolResult, ProtocolState } from "./types";
+export {
+ 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 57e12f2..d42dce7 100644
--- a/src/core/protocol/reducer.test.ts
+++ b/src/core/protocol/reducer.test.ts
@@ -1,151 +1,245 @@
import { describe, expect, it } from "vitest";
-import { applyServerMessage, initialState, invoke, subscribe, unsubscribe } from "./reducer";
+import {
+ 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();
- const result = subscribe(s, "s1");
- s = result.state;
- const spec = makeSpec("s1", "Surface 1");
- const next = applyServerMessage(s, { type: "surface", spec });
- expect(next.subscriptions.get("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(next.subscriptions.get("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", () => {
- 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")).toBeNull();
- });
-
- it("is idempotent — second subscribe 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");
+ });
});
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("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("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 992a918..976eb88 100644
--- a/src/core/protocol/reducer.ts
+++ b/src/core/protocol/reducer.ts
@@ -1,82 +1,143 @@
import type {
- InvokeMessage,
- SubscribeMessage,
- SurfaceServerMessage,
- 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 };
+}
+
+function unsubMsg(surfaceId: string, conversationId: string | undefined): UnsubscribeMessage {
+ return conversationId === undefined
+ ? { type: "unsubscribe", surfaceId }
+ : { type: "unsubscribe", surfaceId, conversationId };
+}
+
+/**
+ * Is an inbound spec/update (which echoes `echoedId`) current for the
+ * subscription whose desired scope is `desiredId`? A scoped surface echoes its
+ * conversationId, so it must match the one we last subscribed with; a GLOBAL
+ * surface echoes nothing (`undefined`) and is always current.
+ */
+function isCurrent(desiredId: string | undefined, echoedId: string | undefined): boolean {
+ 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 surfaceId = msg.spec.id;
- if (!state.subscriptions.has(surfaceId)) return state;
- const subs = new Map(state.subscriptions);
- subs.set(surfaceId, 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 = msg.update.surfaceId;
- if (!state.subscriptions.has(surfaceId)) return state;
- const subs = new Map(state.subscriptions);
- subs.set(surfaceId, msg.update.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 };
+ }
}
/**
- * Subscribe to a surface. Idempotent: if already subscribed, returns the same
- * state with no outgoing message.
+ * Subscribe to a surface for a given conversation (omit `conversationId` for a
+ * GLOBAL surface / when no conversation is focused).
+ *
+ * - Not yet subscribed → emits one `subscribe`.
+ * - Already subscribed with the SAME scope → idempotent no-op.
+ * - Already subscribed with a DIFFERENT conversation (a re-scope on conversation
+ * switch) → emits `unsubscribe` for the old pair then `subscribe` for the new
+ * one, retaining the previous spec until the new one arrives (no flicker).
*/
-export function subscribe(state: ProtocolState, surfaceId: string): ProtocolResult {
- if (state.subscriptions.has(surfaceId)) {
- return { state, outgoing: [] };
- }
- const subs = new Map(state.subscriptions);
- subs.set(surfaceId, null);
- const outgoing: SubscribeMessage = { type: "subscribe", surfaceId };
- return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] };
+export function subscribe(
+ 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 };
}
/**
- * Unsubscribe from a surface. Drops the local spec and emits one unsubscribe.
- * If not subscribed, returns the same state with no outgoing.
+ * Unsubscribe from a surface. Drops the local subscription and emits one
+ * `unsubscribe` (for the conversation pair it was subscribed under). No-op if
+ * not subscribed.
*/
export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult {
- if (!state.subscriptions.has(surfaceId)) {
- return { state, outgoing: [] };
- }
- const subs = new Map(state.subscriptions);
- subs.delete(surfaceId);
- const outgoing: UnsubscribeMessage = { type: "unsubscribe", surfaceId };
- return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] };
+ 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)],
+ };
}
-/** Invoke a field's action on a surface. Emits an InvokeMessage; no state change. */
+/**
+ * Invoke a field's action on a surface. Emits an InvokeMessage (carrying
+ * `conversationId` for a scoped surface); no state change.
+ */
export function invoke(
- state: ProtocolState,
- surfaceId: string,
- actionId: string,
- payload?: unknown,
+ state: ProtocolState,
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
): ProtocolResult {
- const outgoing: InvokeMessage = { type: "invoke", surfaceId, actionId, payload };
- 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;
}
diff --git a/src/core/protocol/types.ts b/src/core/protocol/types.ts
index effec0d..6debb1d 100644
--- a/src/core/protocol/types.ts
+++ b/src/core/protocol/types.ts
@@ -1,22 +1,37 @@
import type {
- SurfaceCatalog,
- SurfaceClientMessage,
- SurfaceErrorMessage,
- SurfaceSpec,
+ SurfaceCatalog,
+ SurfaceClientMessage,
+ SurfaceErrorMessage,
+ SurfaceSpec,
} from "@dispatch/ui-contract";
+/**
+ * One surface subscription's local state.
+ *
+ * `conversationId` is the conversation we last subscribed this surface WITH
+ * (`undefined` = subscribed globally, no conversation in focus). It is the
+ * "desired" scope: an inbound `surface`/`update` that echoes a DIFFERENT
+ * conversation is stale (we have since re-scoped) and is dropped. A GLOBAL
+ * surface ignores the id server-side and echoes none — that (`undefined` echo)
+ * is always accepted. `spec` is `null` until the first `surface` arrives.
+ */
+export interface Subscription {
+ 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; null = subscribed but no spec yet. */
- readonly subscriptions: ReadonlyMap<string, SurfaceSpec | null>;
- /** 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 c0f276f..0d955d5 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -1,181 +1,280 @@
import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+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: "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: {},
- },
- {
- type: "tool-result",
- conversationId: "c1",
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read",
- content: "ok",
- isError: false,
- },
- {
- 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: "error", conversationId: "c1", turnId: "t1", message: "oops" },
- { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" },
- { type: "turn-sealed", conversationId: "c1", turnId: "t1" },
- ];
+ 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",
- "text-delta",
- "reasoning-delta",
- "tool-call",
- "tool-result",
- "tool-output",
- "usage",
- "error",
- "done",
- "turn-sealed",
- ]);
- });
+ 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 11 AgentEvent variants", () => {
- expect(samples).toHaveLength(11);
- });
+ 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" },
+ { type: "image" as const, url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ ];
+ const labels = chunks.map(assertChunkExhaustive);
+ expect(labels).toEqual([
+ "text",
+ "thinking",
+ "tool-call",
+ "tool-result",
+ "error",
+ "system",
+ "image",
+ ]);
+ });
+
+ it("covers all 7 Chunk variants", () => {
+ // Keeps the exhaustive guard honest: a new Chunk.type variant must be added
+ // both here and to `assertChunkExhaustive` or the `satisfies never` errors.
+ expect([
+ "text",
+ "thinking",
+ "tool-call",
+ "tool-result",
+ "error",
+ "system",
+ "image",
+ ] as const).toHaveLength(7);
+ });
});
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" },
- ];
- const labels = msgs.map(assertWsServerMessageExhaustive);
- expect(labels).toEqual(["catalog", "surface", "update", "error", "chat.delta", "chat.error"]);
- });
+ 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" },
- ];
- const labels = msgs.map(assertWsClientMessageExhaustive);
- expect(labels).toEqual(["subscribe", "unsubscribe", "invoke", "chat.send"]);
- });
+ 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");
- });
+ it("constructs a ChatSendMessage with pasted images", () => {
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: "c1",
+ message: "what's in this image?",
+ images: [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/cat.jpg" },
+ ],
+ };
+ expect(msg.images).toHaveLength(2);
+ expect(msg.images?.[0]?.mimeType).toBe("image/png");
+ expect(msg.images?.[1]?.mimeType).toBeUndefined();
+ });
});
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 5d75a60..bfa67dc 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -7,54 +7,64 @@ 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 "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";
- 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";
+ case "image":
+ return "image";
+ default:
+ return chunk satisfies never;
+ }
}
/**
@@ -62,22 +72,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";
- 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;
+ }
}
/**
@@ -85,16 +101,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";
- 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
new file mode 100644
index 0000000..976844b
--- /dev/null
+++ b/src/features/cache-warming/index.ts
@@ -0,0 +1,8 @@
+export type { WarmFeedback, WarmNow } from "./logic/view-model";
+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",
+} as const;
diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts
new file mode 100644
index 0000000..39fec80
--- /dev/null
+++ b/src/features/cache-warming/logic/view-model.test.ts
@@ -0,0 +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,
+} from "./view-model";
+
+const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({
+ 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();
+ });
+});
+
+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();
+ });
+});
+
+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
+ });
+});
+
+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");
+ });
+});
+
+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);
+ });
+});
+
+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();
+ });
+});
diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts
new file mode 100644
index 0000000..bc8cf2e
--- /dev/null
+++ b/src/features/cache-warming/logic/view-model.ts
@@ -0,0 +1,254 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+
+/**
+ * Pure core for the cache-warming view — zero DOM, zero effects, zero Svelte.
+ *
+ * The backend's `cache-warming` surface carries a toggle, a number interval (in
+ * seconds), two `stat`s ("last cache rate" + "cache retention"), and a `custom`
+ * `cache-warming-timer` field bearing the AUTHORITATIVE `nextWarmAt`/`lastWarmAt`
+ * epoch-ms timestamps. This module turns those inputs into the view-model the
+ * (thin) Svelte component renders: parsed controls, a warming-history reducer
+ * keyed off the authoritative `lastWarmAt`, an authoritative countdown, and the
+ * status/format helpers.
+ */
+
+// ── 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 };
+
+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;
+}
+
+const EMPTY_CONTROLS: ParsedControls = {
+ 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). */
+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;
+}
+
+/** A finite number, else null. */
+function numOrNull(v: unknown): number | 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) };
+}
+
+/**
+ * Extract the cache-warming controls from the surface spec by FIELD KIND. The
+ * surface has one toggle, one number, two stats (rate + retention, told apart by
+ * label), and one `custom` timer field. Returns empty defaults when the spec is
+ * 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,
+ };
+}
+
+// ── Interval ↔ minutes/seconds (seconds capped at 59) ─────────────────────────
+
+export interface MinSec {
+ 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)));
+}
+
+export function clampMinutes(n: number): number {
+ 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 };
+}
+
+/** Combine a minutes + seconds pair (each clamped) into total seconds. */
+export function fromMinSec(minutes: number, seconds: number): number {
+ return clampMinutes(minutes) * 60 + clampSeconds(seconds);
+}
+
+// ── Status + formatting ───────────────────────────────────────────────────────
+
+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";
+}
+
+/** 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";
+ }
+}
+
+/** 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`;
+}
+
+/** 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")}`;
+}
+
+// ── 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;
+}
+
+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;
+}
+
+const MAX_HISTORY = 50;
+
+export function initialWarmingState(): WarmingViewState {
+ return { history: [], lastWarmAt: null };
+}
+
+/**
+ * Fold the surface's authoritative `lastWarmAt` + current "last cache rate" into
+ * history. Records a new entry only when `lastWarmAt` CHANGED (a toggle/interval
+ * update re-pushes the same timestamp → no entry), de-duplicated on the timestamp
+ * (not the pct, so two warms with the same % both count). A null `lastWarmAt` is
+ * ignored; a null pct advances the de-dup key without adding an entry.
+ */
+export function observeWarm(
+ 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 };
+}
+
+/**
+ * Grace before a PAST `nextWarmAt` is treated as "not scheduled" (→ the
+ * "waiting…" state instead of a perpetual "0s"). The backend pushes the FUTURE
+ * `nextWarmAt` after every warm (CR-4b) and `null` while generating/disabled, so
+ * this is a belt-and-braces guard that should never trigger — it only matters
+ * against a stale/buggy emitter, and the small window lets an on-time warm show
+ * "0s" for the second it takes to complete.
+ */
+const STALE_NEXT_WARM_MS = 3000;
+
+/**
+ * Seconds until the next automatic warm, AUTHORITATIVE: derived straight from the
+ * backend's `nextWarmAt` epoch-ms (never FE-anchored/guessed). `null` when nothing
+ * is scheduled (disabled, or a turn is generating so the timer is cancelled) — or
+ * 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));
+}
diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte
new file mode 100644
index 0000000..9b3d694
--- /dev/null
+++ b/src/features/cache-warming/ui/CacheWarmingView.svelte
@@ -0,0 +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;
+ }
+ }
+</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}
+</div>
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index f1e8e29..cf57cea 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,7 +1,37 @@
-export type { RenderedChunk } from "../../core/chunks";
-export type { ChatTransport, HistorySync } from "./ports";
+export type {
+ ProviderRetryView,
+ RenderedChunk,
+ RenderGroup,
+ ToolBatchEntry,
+} from "../../core/chunks";
+export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
+export type { TurnMetricsEntry } from "../../core/metrics";
+export { isVisionModel } from "./model-select";
+export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
+export type {
+ EffortOption,
+ ReasoningEffortSaveResult,
+ SaveReasoningEffort,
+} from "./reasoning-effort";
+export {
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ REASONING_EFFORT_LEVELS,
+} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
+export type { CompactNowResult, SaveCompactPercentResult } from "./ui/CompactionView.svelte";
+export { default as CompactionView } from "./ui/CompactionView.svelte";
+export type { ComposerStatus } from "./ui/Composer.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ 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
new file mode 100644
index 0000000..6d3081d
--- /dev/null
+++ b/src/features/chat/model-select.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import {
+ isVisionModel,
+ 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: "" });
+ });
+});
+
+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);
+ });
+});
+
+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([]);
+ });
+});
+
+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([]);
+ });
+});
+
+describe("isVisionModel", () => {
+ it("returns true when modelInfo[name].vision is true", () => {
+ const info = { "kimi/k2": { vision: true } };
+ expect(isVisionModel(info, "kimi/k2")).toBe(true);
+ });
+
+ it("returns false when vision is false", () => {
+ const info = { "umans/glm-5.2": { vision: false } };
+ expect(isVisionModel(info, "umans/glm-5.2")).toBe(false);
+ });
+
+ it("returns false when vision is absent (unknown)", () => {
+ const info = { "umans/glm-5.2": { contextWindow: 128000 } };
+ expect(isVisionModel(info, "umans/glm-5.2")).toBe(false);
+ });
+
+ it("returns false for a model with no metadata entry at all", () => {
+ expect(isVisionModel({}, "unknown/model")).toBe(false);
+ });
+
+ it("returns false for an empty modelInfo map", () => {
+ expect(isVisionModel({}, "kimi/k2")).toBe(false);
+ });
+});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
new file mode 100644
index 0000000..602e0ef
--- /dev/null
+++ b/src/features/chat/model-select.ts
@@ -0,0 +1,64 @@
+import type { ModelMetadata } from "@dispatch/transport-contract";
+
+/**
+ * Pure helpers for the two-step model picker.
+ *
+ * Models arrive from `GET /models` as `<key>/<model>` strings, where `key` is
+ * the credential name (the part before the FIRST slash) and `model` is the rest.
+ * These pure functions split that into a key selector + a model selector and
+ * recombine the choice — zero DOM, zero Svelte.
+ */
+
+export interface SplitModel {
+ 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) };
+}
+
+/** 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}`;
+}
+
+/** 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;
+}
+
+/** 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;
+}
+
+/**
+ * Whether a given full model name (`<key>/<model>`) is vision-capable — i.e.
+ * `GET /models` `modelInfo[name].vision === true`. Absent/`false`/unknown →
+ * `false` (the server's vision handoff transcribes images to text for it).
+ * Pure lookup against the catalog metadata; zero DOM.
+ */
+export function isVisionModel(
+ modelInfo: Readonly<Record<string, ModelMetadata>>,
+ fullName: string,
+): boolean {
+ return modelInfo[fullName]?.vision === true;
+}
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index 07943c7..2fe10dc 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,12 +1,43 @@
-import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+import type {
+ ChatQueueMessage,
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
-/** Injected transport port — sends chat messages to the server. */
+/**
+ * Injected transport port — sends chat messages to the server. Accepts both
+ * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message;
+ * auto-starts a turn if idle).
+ */
export interface ChatTransport {
- send(msg: ChatSendMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage): void;
}
-/** Injected history-sync port — fetches incremental history from the server. */
+/**
+ * Optional windowing for a history fetch ([email protected], CR-5).
+ * 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;
+}
+
+/**
+ * Injected history-sync port — fetches incremental history from the server
+ * (`GET /conversations/:id?sinceSeq=&beforeSeq=&limit=`). NOTE the contract
+ * caveat: on a windowed/backfill read the response's `latestSeq` describes the
+ * returned window, not the conversation's high-water mark — never regress a
+ * tail cursor from it (the FE's cursor comes from the cache's max seq, which
+ * satisfies this naturally).
+ */
export type HistorySync = (
- conversationId: string,
- sinceSeq: number,
+ conversationId: string,
+ sinceSeq: number,
+ window?: HistoryWindow,
) => Promise<ConversationHistoryResponse>;
+
+/** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */
+export type MetricsSync = (conversationId: string) => Promise<ConversationMetricsResponse>;
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
new file mode 100644
index 0000000..6d409e9
--- /dev/null
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import {
+ 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("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("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("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
new file mode 100644
index 0000000..1eb77b6
--- /dev/null
+++ b/src/features/chat/reasoning-effort.ts
@@ -0,0 +1,66 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+
+/**
+ * Pure helpers for the reasoning-effort selector (the thinking-depth knob).
+ *
+ * The canonical ladder + resolution chain are SERVER-owned (`[email protected]`
+ * `ReasoningEffort`; per-turn override → persisted conversation value → default
+ * `"high"`). These helpers only shape the persisted value for display: a `null`
+ * from `GET /conversations/:id/reasoning-effort` means "never set ⇒ the default
+ * applies", so the selector shows `high (default)` — never "off". Zero DOM,
+ * zero Svelte.
+ */
+
+/** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */
+export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+];
+
+/** The server's fallback when nothing is set (the resolution chain's tail). */
+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);
+}
+
+/**
+ * The level the selector should show as selected: the persisted value, or the
+ * server default when never set (`null` = "default applies", not "off").
+ */
+export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort {
+ return persisted ?? DEFAULT_REASONING_EFFORT;
+}
+
+/** One `<option>` of the selector. */
+export interface EffortOption {
+ readonly value: ReasoningEffort;
+ readonly label: string;
+}
+
+/**
+ * The selector's options: every ladder level, with the server default marked
+ * `(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,
+ }));
+}
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's `PUT /conversations/:id/reasoning-effort` to this shape). ────────
+
+/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
+export type ReasoningEffortSaveResult =
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveReasoningEffort = (
+ level: ReasoningEffort,
+) => Promise<ReasoningEffortSaveResult | null>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 1d8ab17..9911438 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,124 +1,409 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ChatSendMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
} from "@dispatch/transport-contract";
-import type { ChatMessage } from "@dispatch/wire";
+import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
import {
- appendUserMessage,
- applyHistory,
- foldEvent,
- initialState,
- selectChunks,
- selectMessages,
+ 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,
+} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
-import type { ChatTransport, HistorySync } from "./ports";
+import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
- readonly conversationId: string;
- readonly model?: string;
- readonly transport: ChatTransport;
- readonly historySync: HistorySync;
- readonly cache: ConversationCache;
+ 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 pendingSync: boolean;
- readonly error: string | null;
- readonly model: string | undefined;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
- send(text: string): void;
- setModel(model: string): void;
- load(): Promise<void>;
- dispose(): void;
+ readonly messages: readonly ChatMessage[];
+ readonly chunks: readonly RenderedChunk[];
+ readonly turnMetrics: readonly TurnMetricsEntry[];
+ /**
+ * The conversation's current context size (tokens occupied) — updated
+ * PROGRESSIVELY: during an in-flight turn, the most recent step's
+ * `inputTokens + outputTokens` (each step's input already includes all prior
+ * context); once the turn seals, its authoritative `contextSize`. `undefined`
+ * ("unknown") when no step has reported usage 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 a user message (start a turn via `chat.send`). Optimistically echoes
+ * the text + any `images` as provisional user chunks (`[text, image, …]` in
+ * order), then forwards them on the WS `chat.send` op. `images` is omitted on
+ * the wire when none are staged (text-only, backward compatible). An
+ * images-only send (empty text) is allowed — the message text is `""`.
+ */
+ send(text: string, images?: readonly ImageInput[]): 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 _pendingSync = $state(false);
- let _error = $state<string | null>(null);
- let _model = $state<string | undefined>(deps.model);
- let disposed = false;
-
- async function syncTail(): Promise<void> {
- if (disposed || _pendingSync) return;
- _pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const res = await deps.historySync(deps.conversationId, since);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
- }
- }
-
- return {
- get messages(): readonly ChatMessage[] {
- return selectMessages(transcript);
- },
- get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
- },
- get pendingSync(): boolean {
- return _pendingSync;
- },
- get error(): string | null {
- return _error;
- },
- get model(): string | undefined {
- return _model;
- },
-
- 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);
- if (transcript.sealedTurnId !== null) {
- void syncTail();
- }
- },
-
- send(text: string): void {
- transcript = appendUserMessage(transcript, text);
- const msg: ChatSendMessage = {
- type: "chat.send",
- conversationId: deps.conversationId,
- message: text,
- ...(_model !== undefined ? { model: _model } : {}),
- };
- deps.transport.send(msg);
- },
-
- setModel(model: string): void {
- _model = model;
- },
-
- async load(): Promise<void> {
- const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
- transcript = applyHistory(transcript, cached);
- }
- await syncTail();
- },
-
- dispose(): void {
- disposed = true;
- },
- };
+ 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);
+
+ /**
+ * 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;
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * 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;
+ },
+
+ 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, images?: readonly ImageInput[]): void {
+ transcript = appendUserMessage(transcript, text, images);
+ maybeTrim();
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: deps.conversationId,
+ message: text,
+ ...(_model !== undefined ? { model: _model } : {}),
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
+ };
+ 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;
+ },
+
+ 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 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();
+ },
+
+ dispose(): void {
+ disposed = true;
+ },
+ };
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index de60b14..8f36994 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,497 +1,1636 @@
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
-import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers";
+import {
+ 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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.dispose();
- });
-
- it("chat.error sets error", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 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,
- 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 cache = createFakeCache();
-
- historySync.returnChunks = [makeStoredChunk(1, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 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,
- 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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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" },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "tool-result",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents",
- isError: false,
- }),
- );
-
- 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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 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();
+ });
+
+ it("send forwards staged images on chat.send and echoes them provisionally", () => {
+ 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,
+ });
+
+ const images: ImageInput[] = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/cat.jpg" },
+ ];
+ store.send("look at this", images);
+
+ expect(transport.sent).toHaveLength(1);
+ const msg = transport.sent[0];
+ expect(msg?.type).toBe("chat.send");
+ expect(msg?.message).toBe("look at this");
+ expect(msg?.images).toEqual(images);
+
+ // Optimistic echo: a text chunk + two image chunks, provisional.
+ const chunks = store.chunks;
+ expect(chunks).toHaveLength(3);
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "look at this" });
+ expect(chunks[1]?.chunk).toEqual({ type: "image", url: images[0]?.url, mimeType: "image/png" });
+ expect(chunks[2]?.chunk).toEqual({ type: "image", url: images[1]?.url });
+
+ store.dispose();
+ });
+
+ it("send omits images on the wire when none are staged (backward compatible)", () => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+
+ store.send("just text");
+
+ expect(transport.sent[0]).not.toHaveProperty("images");
+ store.dispose();
+ });
+
+ it("send omits images on the wire for an empty array", () => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+
+ store.send("just text", []);
+
+ expect(transport.sent[0]).not.toHaveProperty("images");
+ store.dispose();
+ });
+
+ it("send allows an images-only message (empty text)", () => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+
+ store.send("", [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }]);
+
+ expect(transport.sent[0]?.message).toBe("");
+ expect(transport.sent[0]?.images).toHaveLength(1);
+ // The echo is image-only (no text chunk).
+ expect(store.chunks).toHaveLength(1);
+ expect(store.chunks[0]?.chunk.type).toBe("image");
+ 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 d37b59e..26c5590 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -1,83 +1,142 @@
+import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
-import type { ChatTransport, HistorySync } from "./ports";
+import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export interface FakeTransport {
- readonly sent: import("@dispatch/transport-contract").ChatSendMessage[];
- 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: import("@dispatch/transport-contract").ChatSendMessage[] = [];
- return {
- sent,
- impl: {
- send(msg) {
- 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 }>;
- /** 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 }> = [];
- let returnChunks: readonly StoredChunk[] = [];
- return {
- calls,
- get returnChunks() {
- return returnChunks;
- },
- set returnChunks(v: readonly StoredChunk[]) {
- returnChunks = v;
- },
- impl: async (conversationId, sinceSeq) => {
- calls.push({ conversationId, sinceSeq });
- const chunks = returnChunks;
- 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;
+}
+
+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 };
+ },
+ };
}
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 ac8f640..5f8067d 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -1,298 +1,1165 @@
-import { render, screen } from "@testing-library/svelte";
+import type { StepId } from "@dispatch/wire";
+import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
+import type { TurnMetricsEntry } from "../../core/metrics";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
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();
- expect(screen.getByText("assistant")).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("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("marks provisional chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "text", text: "Streaming..." },
- provisional: true,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- const bubble = screen.getByText("Streaming...").closest(".chat-bubble");
- expect(bubble).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("thinking <details> stays open across a streaming update", async () => {
- const initial: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think..." },
- provisional: true,
- },
- ];
-
- const { rerender } = render(ChatView, { props: { chunks: initial } });
-
- const details = screen.getByText("Thinking").closest("details");
- expect(details).not.toBeNull();
- expect(details).not.toHaveAttribute("open");
- if (details) details.open = true;
- expect(details).toHaveAttribute("open");
-
- const updated: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think... step by step" },
- provisional: true,
- },
- ];
- await rerender({ chunks: updated });
-
- const detailsAfter = screen.getByText("Thinking").closest("details");
- expect(detailsAfter).not.toBeNull();
- expect(detailsAfter).toHaveAttribute("open");
- expect(detailsAfter).toHaveTextContent("Let me think... step by step");
- });
+ 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 user image chunk as an <img> with the chunk's url", () => {
+ const url = "data:image/png;base64,AAAA";
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "image", url, mimeType: "image/png" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ const img = container.querySelector("img");
+ expect(img).not.toBeNull();
+ expect(img?.getAttribute("src")).toBe(url);
+ expect(img?.getAttribute("alt")).toBe("image/png");
+ expect(img?.getAttribute("loading")).toBe("lazy");
+ });
+
+ it("resolves a persisted image chunk's relative url against apiBaseUrl", () => {
+ // Persisted image chunks now carry a compact relative path (`/images/…`)
+ // served by the backend — prepend the API base to render them.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "image", url: "/images/conv-123/abc-456.png", mimeType: "image/png" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/images/conv-123/abc-456.png",
+ );
+ });
+
+ it("passes a data URL through unchanged even with apiBaseUrl set (optimistic echo)", () => {
+ // The optimistic echo (what the FE just sent) is still a data URL; it must
+ // NOT be mangled by the base-URL prepend.
+ const dataUrl = "data:image/png;base64,iVBOR=";
+ const chunks: RenderedChunk[] = [
+ { seq: null, role: "user", chunk: { type: "image", url: dataUrl }, provisional: true },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(dataUrl);
+ });
+
+ it("leaves a relative image url root-relative when apiBaseUrl is absent", () => {
+ // No apiBaseUrl → a browser resolves `/images/…` against the document origin.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "image", url: "/images/conv-1/x.png" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("/images/conv-1/x.png");
+ });
+
+ it("renders a multi-chunk user message [text, image] and a transcription text", () => {
+ // A non-vision model: the server persists the original image chunk AND a
+ // transcription text chunk in the SAME user message — render both.
+ const url = "data:image/png;base64,BBQ=";
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "describe this" }, provisional: false },
+ {
+ seq: 2,
+ role: "user",
+ chunk: { type: "image", url, mimeType: "image/png" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "user",
+ chunk: { type: "text", text: "[Image analysis (via kimi/k2)]: a red square" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("describe this")).toBeInTheDocument();
+ expect(screen.getByText(/\[Image analysis/)).toBeInTheDocument();
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(url);
+ });
+
+ it("renders a consult_vision tool call/result like any other tool", () => {
+ // read_image is GONE — replaced by consult_vision (opens a vision-model
+ // conversation, attaches the image + question, returns the answer). It is
+ // a normal tool call: rendered generically by toolName.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "consult_vision",
+ input: { question: "what is in this image?", imageIds: [1] },
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "consult_vision",
+ content: "a red square on a white background",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0);
+ expect(screen.getByText("a red square on a white background")).toBeInTheDocument();
+ });
+
+ it("renders a non-vision placeholder text chunk as-is", () => {
+ // A non-vision model gets a numbered placeholder (a regular text chunk)
+ // instead of an auto-transcription. Renders like any text chunk.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: {
+ type: "text",
+ text: "[Image 1 attached — call consult_vision with imageIds=[1] and a specific question to analyze it]",
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText(/\[Image 1 attached/)).toBeInTheDocument();
+ expect(screen.getByText(/consult_vision with imageIds/)).toBeInTheDocument();
+ });
+
+ it("renders a compacted-image text chunk as-is", () => {
+ // Image compaction transcribes old images to [Compacted image]: <desc>.
+ // Regular text chunk — render as-is.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "[Compacted image]: a chart showing rising sales" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText(/\[Compacted image\]/)).toBeInTheDocument();
+ expect(screen.getByText(/rising sales/)).toBeInTheDocument();
+ });
});
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 } });
+
+ 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);
+
+ expect(onSend).toHaveBeenCalledTimes(1);
+ expect(onSend).toHaveBeenCalledWith("Hello world", undefined);
+ expect(textarea).toHaveValue("");
+ });
+
+ it("does not call onSend with empty text", async () => {
+ const onSend = vi.fn();
+ const _user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton).toBeDisabled();
+
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("trims whitespace before sending", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, " hello ");
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
+
+ expect(onSend).toHaveBeenCalledWith("hello", undefined);
+ });
+
+ it("sends on Enter key (without Shift)", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Test message{Enter}");
+
+ expect(onSend).toHaveBeenCalledWith("Test message", undefined);
+ });
+
+ it("does not send on Shift+Enter", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("stages a pasted image and forwards it on send", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- render(Composer, { props: { onSend } });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "look at this");
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Hello world");
+ // jsdom has no ClipboardEvent/DataTransfer: dispatch a plain paste event
+ // carrying a mock clipboardData whose only item is an image File.
+ const file = new File(["PNG"], "shot.png", { type: "image/png" });
+ const paste = new Event("paste", { bubbles: true });
+ Object.defineProperty(paste, "clipboardData", {
+ value: {
+ items: [{ kind: "file", type: "image/png", getAsFile: () => file }],
+ },
+ });
+ container.querySelector("textarea")?.dispatchEvent(paste);
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
- expect(onSend).toHaveBeenCalledTimes(1);
- expect(onSend).toHaveBeenCalledWith("Hello world");
- expect(textarea).toHaveValue("");
- });
+ await user.click(screen.getByRole("button", { name: "Send" }));
- it("does not call onSend with empty text", async () => {
- const onSend = vi.fn();
- const _user = userEvent.setup();
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images).toHaveLength(1);
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ expect(images[0]?.mimeType).toBe("image/png");
+ });
- render(Composer, { props: { onSend } });
+ it("lets a text paste proceed when no image is on the clipboard", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- const sendButton = screen.getByRole("button", { name: "Send" });
- expect(sendButton).toBeDisabled();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "hello");
- expect(onSend).not.toHaveBeenCalled();
- });
+ // A text-only paste: no file items → the component must NOT preventDefault,
+ // so the default text paste path is unaffected (no image staged).
+ const paste = new Event("paste", { bubbles: true });
+ Object.defineProperty(paste, "clipboardData", {
+ value: { items: [{ kind: "string", type: "text/plain" }] },
+ });
+ container.querySelector("textarea")?.dispatchEvent(paste);
- it("trims whitespace before sending", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
- render(Composer, { props: { onSend } });
+ it("stages an image via the attach button's file picker", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, " hello ");
+ const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
- expect(onSend).toHaveBeenCalledWith("hello");
- });
+ // Image-only send (no text): the Send button is enabled.
+ const send = screen.getByRole("button", { name: "Send" });
+ expect(send).not.toBeDisabled();
+ await user.click(send);
- it("sends on Enter key (without Shift)", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [text, images] = onSend.mock.calls[0] ?? [];
+ expect(text).toBe("");
+ expect(images).toHaveLength(1);
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ });
- render(Composer, { props: { onSend } });
+ it("removes a staged image via the remove button", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Test message{Enter}");
+ const file = new File(["PNG"], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
- expect(onSend).toHaveBeenCalledWith("Test message");
- });
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+ await user.click(screen.getByRole("button", { name: "Remove image" }));
- it("does not send on Shift+Enter", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ // With no text and no images, Send is disabled again.
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ });
- render(Composer, { props: { onSend } });
+ it("ignores a non-image file chosen via the picker", async () => {
+ const onSend = vi.fn();
+ const { container } = render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+ const file = new File(["TXT"], "notes.txt", { type: "text/plain" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
- expect(onSend).not.toHaveBeenCalled();
- });
+ // Give the async staging a chance; a non-image is skipped.
+ await new Promise((r) => setTimeout(r, 0));
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it("queues (steers) text-only and never forwards images", async () => {
+ // While running, the Send button becomes "Queue"; steering is text-only.
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "steer here");
+
+ // Also stage an image — it must NOT be forwarded on a queue.
+ const file = new File(["PNG"], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onSend).not.toHaveBeenCalled();
+ });
});
describe("ModelSelector", () => {
- it("renders the options and current selection", () => {
- const models = ["openai/gpt-4", "anthropic/claude-3", "google/gemini"];
- render(ModelSelector, {
- props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
- });
-
- const select = screen.getByRole("combobox", { name: "Model selector" });
- expect(select).toBeInTheDocument();
- expect(select).toHaveValue("anthropic/claude-3");
-
- const options = screen.getAllByRole("option");
- expect(options).toHaveLength(3);
- expect(options[0]).toHaveValue("openai/gpt-4");
- expect(options[1]).toHaveValue("anthropic/claude-3");
- expect(options[2]).toHaveValue("google/gemini");
- });
-
- it("calls onSelect on change", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "anthropic/claude-3"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4", onSelect },
- });
-
- const select = screen.getByRole("combobox", { name: "Model selector" });
- await user.selectOptions(select, "anthropic/claude-3");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
- });
+ 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");
+ });
+
+ it("marks vision-capable models in the model dropdown", () => {
+ const models = ["kimi/k2", "kimi/k1.5"];
+ const modelInfo = {
+ "kimi/k2": { vision: true },
+ "kimi/k1.5": { vision: false },
+ };
+ render(ModelSelector, {
+ props: { models, selected: "kimi/k2", onSelect: vi.fn(), modelInfo },
+ });
+
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ const options = within(modelSelect).getAllByRole("option");
+ expect(options).toHaveLength(2);
+ expect(options[0]?.textContent).toContain("vision");
+ expect(options[1]?.textContent).not.toContain("vision");
+ });
+
+ it("shows the vision indicator when the selected model is vision-capable", () => {
+ render(ModelSelector, {
+ props: {
+ models: ["kimi/k2"],
+ selected: "kimi/k2",
+ onSelect: vi.fn(),
+ modelInfo: { "kimi/k2": { vision: true } },
+ },
+ });
+ expect(screen.getByText(/sees images natively/)).toBeInTheDocument();
+ });
+
+ it("shows the vision-handoff hint when the selected model is non-vision", () => {
+ render(ModelSelector, {
+ props: {
+ models: ["umans/glm-5.2"],
+ selected: "umans/glm-5.2",
+ onSelect: vi.fn(),
+ modelInfo: { "umans/glm-5.2": { vision: false } },
+ },
+ });
+ expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
+ expect(screen.queryByText(/sees images natively/)).not.toBeInTheDocument();
+ });
+
+ it("shows the handoff hint when modelInfo is absent", () => {
+ render(ModelSelector, {
+ props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() },
+ });
+ expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
+ });
+});
+
+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();
+ });
+ });
});
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index cb6069b..e67ca5b 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,47 +1,368 @@
<script lang="ts">
- import type { RenderedChunk } from "../index";
+ import type { TurnProviderRetryEvent } from "@dispatch/wire";
+ import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index";
+ import {
+ interleaveTurnMetrics,
+ viewCacheRate,
+ viewExpectedCache,
+ viewStepMetrics,
+ viewTurnMetrics,
+ type TurnMetricsEntry,
+ } from "../../../core/metrics";
+ import { Markdown } from "../../markdown";
- let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
+
+ let {
+ chunks,
+ turnMetrics = [],
+ hasEarlier = false,
+ onShowEarlier,
+ thinkingKeyBase = 0,
+ providerRetry = null,
+ apiBaseUrl = "",
+ }: {
+ 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;
+ /**
+ * The HTTP API base URL (e.g. `http://localhost:24203`). Persisted image
+ * chunks carry a compact relative path (`/images/<conv>/<uuid>.png`); this
+ * base is prepended to render them. The optimistic echo's data URL and any
+ * absolute URL pass through unchanged (see `resolveImageUrl`). Defaults to
+ * "" (root-relative — a browser resolves `/images/…` against its origin).
+ */
+ apiBaseUrl?: string;
+ } = $props();
+
+ // 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;
+ }
+ }
+
+ const groups = $derived(groupRenderedChunks(chunks));
+
+ 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 };
+ });
+ });
</script>
-<div class="flex flex-col gap-2 p-4" role="log" aria-live="polite">
- {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)}
- <div class="chat {rendered.role === 'user' ? 'chat-start' : 'chat-end'}">
- <div class="chat-header text-xs opacity-70">{rendered.role}</div>
- <div
- class="chat-bubble"
- class:chat-bubble-primary={rendered.role === "user"}
- class:chat-bubble-secondary={rendered.role === "assistant"}
- class:opacity-50={rendered.provisional}
- >
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {:else if rendered.chunk.type === "thinking"}
- <details>
- <summary>Thinking</summary>
- <p>{rendered.chunk.text}</p>
- </details>
- {:else if rendered.chunk.type === "tool-call"}
- <div class="text-sm">
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- {:else if rendered.chunk.type === "tool-result"}
- <div class="text-sm" class:text-error={rendered.chunk.isError}>
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{rendered.chunk.content}</pre>
- </div>
- {: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>
- {/each}
+{#snippet chunkRow(rendered: RenderedChunk)}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
+ ([text, image, image, …]); each chunk renders in its own bubble. A
+ persisted image chunk's url is a compact relative path (`/images/…`)
+ served by the backend — resolve it against the API base. The
+ optimistic echo's data URL (and any absolute URL) passes through. -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary">
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {:else if rendered.chunk.type === "image"}
+ <img
+ src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
+ alt={rendered.chunk.mimeType ?? "pasted image"}
+ loading="lazy"
+ decoding="async"
+ class="max-h-80 max-w-full rounded"
+ />
+ {/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 pt-4 pb-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}
</div>
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte
new file mode 100644
index 0000000..5014e5c
--- /dev/null
+++ b/src/features/chat/ui/CompactionView.svelte
@@ -0,0 +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 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();
+
+ const DEFAULT_PERCENT = 85;
+
+ 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);
+
+ // 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}%`,
+ );
+
+ 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;
+ }
+ }
+</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>
+
+ <!-- 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 3762340..04c28cd 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,33 +1,413 @@
<script lang="ts">
- let { onSend }: { onSend: (text: string) => void } = $props();
-
- let text = $state("");
-
- function handleSubmit(): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- onSend(trimmed);
- text = "";
- }
-
- function handleKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
+ import type { ImageInput } from "@dispatch/wire";
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
+ const MAX_LINES = 7;
+ /** Accept only raster images (the provider image-content formats). */
+ const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp";
+ /** Reject images larger than this before base64-encoding (keeps payloads sane). */
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
+
+ /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */
+ interface StagedImage {
+ readonly id: string;
+ readonly input: ImageInput;
+ }
+
+ let {
+ onSend,
+ onQueue,
+ onStop,
+ contextSize = undefined,
+ contextWindow = undefined,
+ status = "idle",
+ }: {
+ /**
+ * Send a message (start a turn via `chat.send`). Carries any staged images
+ * as `ImageInput[]` (base64 data URLs or https URLs); the store forwards
+ * them on the WS `chat.send` op / `POST /chat` body. `images` is omitted
+ * (not an empty array) when none are staged, so the wire stays text-only.
+ */
+ onSend: (text: string, images?: ImageInput[]) => 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. Steering is text-only —
+ * it never carries images (a mid-turn injection has no image surface).
+ */
+ onQueue?: (text: string) => void;
+ /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
+ onStop?: () => void;
+ // Current context occupancy — updated progressively during a turn (the
+ // latest step's input+output) and finalized to the turn's `contextSize` on
+ // seal, 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. `queued` = the turn is in
+ * flight but waiting for a concurrency slot (CR-13) — shown as a loading
+ * RING (vs the loading DOTS of `running`/actively generating). Behaves like
+ * `running` for the send button (steer/stop).
+ */
+ status?: ComposerStatus;
+ } = $props();
+
+ export type ComposerStatus = "idle" | "running" | "queued" | "error";
+
+ let text = $state("");
+ let images = $state<StagedImage[]>([]);
+ let inputEl: HTMLTextAreaElement | undefined;
+ let fileInputEl: HTMLInputElement | undefined;
+ let dragOver = $state(false);
+
+ const hasText = $derived(text.trim().length > 0);
+ const hasImages = $derived(images.length > 0);
+ const canSend = $derived(hasText || hasImages);
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
+
+ // One button, three modes:
+ // - idle → "Send" (starts a turn via chat.send)
+ // - running/queued + text → "Queue" (steers via chat.queue — text only)
+ // - running/queued + empty → "Stop" (aborts via POST /stop)
+ // (`queued` behaves like `running` — the turn is in flight, just waiting for a
+ // concurrency slot; the user can still steer or stop it.)
+ // Steering never carries images: when running with images staged but no text,
+ // the images stay staged (queue is text-only). Images-without-text while running
+ // is an unusual case that still sends (the server auto-starts/resolves).
+ const inFlight = $derived(status === "running" || status === "queued");
+ const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
+ if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop";
+ if (inFlight && hasText && onQueue !== undefined) return "queue";
+ return "send";
+ });
+ const placeholder = $derived(
+ status === "queued"
+ ? "Queued for a slot…"
+ : status === "running"
+ ? "Steer the conversation..."
+ : "Type a message, paste or drop an image…",
+ );
+
+ // 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";
+ }
+
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
+
+ /** Read a File into a base64 data URL (`data:image/…;base64,…`). */
+ function fileToDataUrl(file: File): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result === "string") resolve(reader.result);
+ else reject(new Error("unreadable image"));
+ };
+ reader.onerror = () => reject(reader.error ?? new Error("read failed"));
+ reader.readAsDataURL(file);
+ });
+ }
+
+ let imgSeq = 0;
+ /** Stage a File as an image (skip non-images / oversized). Returns whether staged. */
+ async function stageFile(file: File): Promise<boolean> {
+ if (!file.type.startsWith("image/")) return false;
+ if (file.size > MAX_IMAGE_BYTES) return false;
+ const url = await fileToDataUrl(file);
+ // Prefer the file's declared MIME; fall back to the data URL's prefix.
+ const mimeType = file.type || undefined;
+ const id = `img-${Date.now()}-${imgSeq++}`;
+ images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }];
+ return true;
+ }
+
+ function removeImage(id: string): void {
+ images = images.filter((img) => img.id !== id);
+ }
+
+ /** Handle a paste anywhere in the form: extract image items from the clipboard. */
+ async function handlePaste(e: ClipboardEvent): Promise<void> {
+ const items = e.clipboardData?.items;
+ if (items === undefined) return;
+ let hadImage = false;
+ const staged: File[] = [];
+ for (const item of items) {
+ if (item.kind === "file" && item.type.startsWith("image/")) {
+ const file = item.getAsFile();
+ if (file !== null) {
+ staged.push(file);
+ hadImage = true;
+ }
+ }
+ }
+ if (!hadImage) return; // let the default text paste proceed
+ e.preventDefault(); // suppress pasting the image as a filename string
+ for (const file of staged) {
+ await stageFile(file);
+ }
+ }
+
+ /** File-picker <input type="file"> change. */
+ async function handleFilePick(e: Event): Promise<void> {
+ const target = e.currentTarget as HTMLInputElement;
+ const files = target.files;
+ if (files === null) return;
+ for (const file of files) {
+ await stageFile(file);
+ }
+ target.value = ""; // reset so picking the same file again re-fires change
+ }
+
+ /** Drop images onto the composer. */
+ async function handleDrop(e: DragEvent): Promise<void> {
+ dragOver = false;
+ const files = e.dataTransfer?.files;
+ if (files === undefined || files.length === 0) return;
+ const hadImage = Array.from(files).some((f) => f.type.startsWith("image/"));
+ if (!hadImage) return;
+ e.preventDefault();
+ for (const file of files) {
+ await stageFile(file);
+ }
+ }
+
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ // Allow a send with images even when text is empty (an image-only turn).
+ if (trimmed.length === 0 && !hasImages) return;
+ if (buttonMode === "queue") {
+ // Steering is text-only — never forward images.
+ onQueue?.(trimmed);
+ } else {
+ const toSend: ImageInput[] | undefined = hasImages
+ ? images.map((img) => img.input)
+ : undefined;
+ onSend(trimmed, toSend);
+ }
+ text = "";
+ images = [];
+ }
+
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
</script>
-<form class="flex gap-2 p-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
- <textarea
- class="textarea textarea-bordered flex-1"
- bind:value={text}
- onkeydown={handleKeydown}
- placeholder="Type a message..."
- rows="3"
- aria-label="Message input"
- ></textarea>
- <button class="btn btn-primary" type="submit" disabled={text.trim().length === 0}>
- Send
- </button>
+<form
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
+ ondrop={handleDrop}
+ ondragover={(e) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ dragOver = true;
+ }
+ }}
+ ondragleave={() => (dragOver = false)}
+>
+ <!-- Top bar: expanding textarea + image-attach button + single context-aware button -->
+ <div class="flex items-end gap-2 px-4 pt-3 pb-2">
+ <div
+ class="flex-1"
+ onpaste={handlePaste}
+ class:border-2={dragOver}
+ class:border-primary={dragOver}
+ class:border-dashed={dragOver}
+ class:rounded={dragOver}
+ >
+ <textarea
+ bind:this={inputEl}
+ class="textarea textarea-bordered w-full resize-none leading-normal !min-h-0 h-auto"
+ bind:value={text}
+ onkeydown={handleKeydown}
+ {placeholder}
+ rows="1"
+ aria-label="Message input"></textarea>
+ </div>
+
+ <!-- Hidden file picker (images only; multiple). -->
+ <input
+ bind:this={fileInputEl}
+ type="file"
+ accept={IMAGE_ACCEPT}
+ multiple
+ class="hidden"
+ onchange={handleFilePick}
+ />
+ <!-- Attach image button (opens the file picker). -->
+ <button
+ class="btn btn-ghost btn-square shrink-0"
+ type="button"
+ aria-label="Attach image"
+ title="Attach image"
+ onclick={() => fileInputEl?.click()}
+ >
+ <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-5 w-5"
+ >
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
+ <polyline points="21 15 16 10 5 21"></polyline>
+ </svg>
+ </button>
+
+ {#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={!canSend}>
+ {buttonMode === "queue" ? "Queue" : "Send"}
+ </button>
+ {/if}
+ </div>
+
+ <!-- Staged image thumbnails (previews) with remove buttons. -->
+ {#if hasImages}
+ <div class="flex flex-wrap gap-2 px-4 pb-1">
+ {#each images as img (img.id)}
+ <div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300">
+ <img
+ src={img.input.url}
+ alt={img.input.mimeType ?? "staged image"}
+ class="h-full w-full object-cover"
+ />
+ <button
+ class="btn btn-circle btn-xs absolute right-0 top-0 bg-base-100/80 hover:bg-error hover:text-error-content"
+ type="button"
+ aria-label="Remove image"
+ onclick={() => removeImage(img.id)}
+ >
+ <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-3 w-3"
+ >
+ <line x1="18" y1="6" x2="6" y2="18"></line>
+ <line x1="6" y1="6" x2="18" y2="18"></line>
+ </svg>
+ </button>
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- 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 === "queued"}
+ <!-- Waiting for a concurrency slot — a ring (vs the dots of `running`). -->
+ <span
+ class="loading loading-ring loading-xs text-primary"
+ aria-label="Queued"
+ title="Waiting for a concurrency slot"
+ ></span>
+ {:else if status === "running"}
+ <span class="loading loading-dots 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}
+
+ <span class="shrink-0 whitespace-nowrap font-mono">
+ {#if usage.current !== null}
+ {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 3e25ec3..11b9feb 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,22 +1,92 @@
<script lang="ts">
- let {
- models,
- selected,
- onSelect,
- }: {
- models: readonly string[];
- selected: string;
- onSelect: (model: string) => void;
- } = $props();
+ import type { ModelMetadata } from "@dispatch/transport-contract";
+ import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+
+ let {
+ models,
+ selected,
+ onSelect,
+ modelInfo = {},
+ }: {
+ models: readonly string[];
+ selected: string;
+ onSelect: (model: string) => void;
+ /**
+ * Per-model metadata from `GET /models` (`{ [name]: ModelMetadata }`).
+ * Used to show a "vision" badge next to models with `vision: true` (they
+ * natively accept images; others rely on the server's vision handoff).
+ * Optional — absent metadata → no badge (treated as non-vision).
+ */
+ modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ } = $props();
+
+ const keys = $derived(modelKeys(models));
+ const current = $derived(splitModelName(selected));
+ const keyModels = $derived(modelsForKey(models, current.key));
+
+ // Whether the currently-selected full model name is vision-capable.
+ const selectedVision = $derived(isVisionModel(modelInfo, selected));
+
+ // 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));
+ }
+
+ // The full `<key>/<model>` name for a model suffix under the current key.
+ function fullNameFor(modelSuffix: string): string {
+ return joinModelName(current.key, modelSuffix);
+ }
</script>
-<select
- class="select"
- value={selected}
- onchange={(e) => onSelect(e.currentTarget.value)}
- aria-label="Model selector"
->
- {#each models as model (model)}
- <option value={model}>{model}</option>
- {/each}
-</select>
+<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}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+ </option>
+ {/each}
+ </select>
+ {#if selectedVision}
+ <div class="flex items-center gap-1 text-xs text-base-content/60">
+ <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-3.5 w-3.5"
+ aria-hidden="true"
+ >
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+ <circle cx="12" cy="12" r="3"></circle>
+ </svg>
+ <span>Vision — this model sees images natively</span>
+ </div>
+ {:else}
+ <div class="text-xs text-base-content/40">
+ Pasted images are auto-described (vision handoff)
+ </div>
+ {/if}
+</div>
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
new file mode 100644
index 0000000..d982905
--- /dev/null
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ 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();
+
+ 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);
+
+ 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
+ }
+ }
+</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}
+</div>
diff --git a/src/features/computer/index.ts b/src/features/computer/index.ts
new file mode 100644
index 0000000..05e56cc
--- /dev/null
+++ b/src/features/computer/index.ts
@@ -0,0 +1,31 @@
+export type {
+ Badge,
+ ComputerListResult,
+ ComputerSaveResult,
+ ComputerStatusResult,
+ ComputerStatusView,
+ ComputerView,
+ LoadComputerStatus,
+ LoadComputers,
+ SaveComputer,
+ TestComputer,
+ TestComputerResult,
+ TestResultView,
+} from "./logic/view-model";
+export {
+ 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",
+} as const;
diff --git a/src/features/computer/logic/view-model.test.ts b/src/features/computer/logic/view-model.test.ts
new file mode 100644
index 0000000..45d459e
--- /dev/null
+++ b/src/features/computer/logic/view-model.test.ts
@@ -0,0 +1,167 @@
+import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract";
+import type { ComputerEntry } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ 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,
+ };
+}
+
+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("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");
+ });
+});
+
+describe("knownHostLabel", () => {
+ 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");
+ });
+});
+
+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();
+ });
+});
+
+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]");
+ });
+});
+
+describe("summarizeComputers", () => {
+ 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 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);
+ });
+});
+
+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");
+ });
+});
diff --git a/src/features/computer/logic/view-model.ts b/src/features/computer/logic/view-model.ts
new file mode 100644
index 0000000..d489fb5
--- /dev/null
+++ b/src/features/computer/logic/view-model.ts
@@ -0,0 +1,184 @@
+import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract";
+import type { Computer, ComputerEntry } from "@dispatch/wire";
+
+/**
+ * Pure core for the computer feature — zero DOM, zero effects, zero Svelte.
+ *
+ * A **computer** is a remote SSH target discovered from the user's `~/.ssh/config`
+ * (read-only — there is no CRUD store; the user edits their config to add one).
+ * The computer feature surfaces, per-conversation and per-workspace, WHICH
+ * computer a turn's tools execute on (the computer analog of `cwd`). It is a
+ * USER-facing control only: the computer is a tool-execution target forwarded to
+ * tools and NEVER part of the model prompt (so it does not affect prompt
+ * caching) — the agent never sees it.
+ *
+ * This module holds the pure logic: formatting a `Computer` for display, mapping a
+ * live `ComputerStatusResponse.state` to a display badge, and the one-line
+ * discovered-list summary. The effects (the HTTP list/get/status/test +
+ * per-conversation get/set) are INJECTED via the ports below; the composition root
+ * implements them.
+ */
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). Mirrors cwd-lsp's ports. ──────────────
+
+/** 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 };
+
+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 };
+
+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 };
+
+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 };
+
+export type TestComputer = (alias: string) => Promise<TestComputerResult | null>;
+
+// ── Computer → display view ──────────────────────────────────────────────────
+
+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;
+}
+
+/**
+ * Format a computer's connection target as `user@host:port`, omitting the port
+ * when it is the SSH default (22) so the summary stays compact (mirrors how a user
+ * reads an ssh config `Host` block). `hostName` falls back to the alias itself
+ * (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}`;
+}
+
+/** The display label for the known-host indicator. */
+export function knownHostLabel(knownHost: boolean): string {
+ 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,
+ };
+}
+
+export function viewComputers(computers: readonly ComputerEntry[]): readonly ComputerView[] {
+ return computers.map(viewComputer);
+}
+
+/**
+ * A one-line summary of the discovered list, e.g. "3 computers" / "No computers
+ * 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"}`;
+}
+
+// ── 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;
+}
+
+/**
+ * Map a computer's live connection state to a display label + badge + busy flag.
+ * Mirrors the LSP/MCP status→badge pattern (each feature owns its own mapping —
+ * the enums differ, so they are not shared). Exhaustive vs the contract:
+ * `connected`→success, `connecting`→warning+busy, `disconnected`→neutral (a stable
+ * 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,
+ };
+}
+
+// ── 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;
+}
+
+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",
+ };
+}
diff --git a/src/features/computer/ui/ComputerField.svelte b/src/features/computer/ui/ComputerField.svelte
new file mode 100644
index 0000000..6b54c88
--- /dev/null
+++ b/src/features/computer/ui/ComputerField.svelte
@@ -0,0 +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";
+ }
+ });
+</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>
+</div>
diff --git a/src/features/computer/ui/ComputerSelect.svelte b/src/features/computer/ui/ComputerSelect.svelte
new file mode 100644
index 0000000..c580b60
--- /dev/null
+++ b/src/features/computer/ui/ComputerSelect.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ 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();
+
+ // 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);
+ }
+</script>
+
+<select
+ 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}
+</select>
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts
new file mode 100644
index 0000000..c0870e3
--- /dev/null
+++ b/src/features/concurrency/index.ts
@@ -0,0 +1,63 @@
+export type {
+ // Contract shapes re-exported for a single import surface.
+ ConcurrencyCooldownResponse,
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitEntry,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResponse,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ ConcurrencyStatusResult,
+ DeleteConcurrencyLimit,
+ GetConcurrencyCooldown,
+ GetConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+} from "./logic/types";
+export type {
+ AutoReduceNotice,
+ Badge,
+ ConcurrencyLimitView,
+ ConcurrencyStatusView,
+} from "./logic/view-model";
+export {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
+ formatPauseDuration,
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ normalizeLimit,
+ parseCooldownInput,
+ parseLimitInput,
+ pauseLabel,
+ providerFromModel,
+ providerOptions,
+ statusLabel,
+ summarizeLimits,
+ summarizeStatus,
+ viewAutoReduce,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./logic/view-model";
+export { default as AutoReduceBanner } from "./ui/AutoReduceBanner.svelte";
+export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte";
+export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "concurrency",
+ description: "Per-provider concurrency limits + live in-flight/queue status",
+} as const;
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts
new file mode 100644
index 0000000..a0c5f6b
--- /dev/null
+++ b/src/features/concurrency/logic/types.ts
@@ -0,0 +1,119 @@
+import type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte.
+ *
+ * The backend tracks + limits how many concurrent token-generating API requests
+ * are in flight PER PROVIDER. When the cap is reached, additional requests queue
+ * and are granted slots oldest-agent-first; a 429 backoff PAUSES a provider's
+ * queue until `pausedUntil`. The cap is in-memory + per-provider (no persistence),
+ * managed via a plain REST surface under `/concurrency/...` provided by the
+ * `concurrency` extension. When the extension isn't loaded, the list + status
+ * endpoints return empty arrays (`{ limits: [] }` / `{ providers: [] }`); the
+ * single / PUT / DELETE endpoints return `503`.
+ *
+ * The data shapes ARE part of `@dispatch/transport-contract` (0.23.0), so they
+ * are imported directly (mirrors `mcp` / `computer`). The result types + injected
+ * ports below are FE-owned (the composition root adapts the store's HTTP calls to
+ * them). The endpoints are GLOBAL (not workspace- or conversation-scoped).
+ *
+ * Concurrency-fixes (additive, no version bump): each `ConcurrencyStatusEntry`
+ * now also carries `cooldownMs` (per-slot release cooldown, configurable +
+ * persisted), `autoReduced` (a 429 auto-reduced the limit by 1, one-way), and
+ * when auto-reduced, `autoReducedFrom` + a `notice` banner string. A manual
+ * `PUT /concurrency/limits/:providerId` clears `autoReduced`. Two new endpoints
+ * `GET`/`PUT /concurrency/cooldown/:providerId` view/change the cooldown.
+ */
+
+/** Re-export the contract shapes so consumers import a single surface. */
+export type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyCooldownRequest,
+ SetConcurrencyLimitRequest,
+};
+
+/**
+ * A configured concurrency limit — one provider's cap on in-flight requests.
+ * Same shape as the contract's `ConcurrencyLimitResponse`.
+ */
+export interface ConcurrencyLimitEntry {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+// ── Result types (port outcomes; the store returns these directly) ──────────────
+
+/** Outcome of `GET /concurrency/limits` (all configured limits). */
+export type ConcurrencyLimitsResult =
+ | { readonly ok: true; readonly limits: readonly ConcurrencyLimitEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `GET`/`PUT /concurrency/limits/:providerId` — the configured limit
+ * for one provider. `GET` returns `404` when the provider has no limit (surfaced
+ * as `ok: false`); `PUT` returns `400` for a non-positive-integer body.
+ */
+export type ConcurrencyLimitResult =
+ | { readonly ok: true; readonly providerId: string; readonly limit: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `DELETE /concurrency/limits/:providerId` (remove → unlimited). */
+export type ConcurrencyDeleteResult =
+ | { readonly ok: true; readonly providerId: string }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /concurrency/status` (live status for every limited provider). */
+export type ConcurrencyStatusResult =
+ | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `GET`/`PUT /concurrency/cooldown/:providerId` — the per-slot
+ * release cooldown (ms) for one provider. `GET` returns `404` when the provider
+ * has no concurrency config at all (no limit, no cooldown); `PUT` returns `400`
+ * for a non-negative-integer body. Both return `503` when the extension isn't
+ * loaded.
+ */
+export type ConcurrencyCooldownResult =
+ | { readonly ok: true; readonly providerId: string; readonly cooldownMs: number }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of an auto-reduce banner's "Restore to N" action (PUT the limit back
+ * to `autoReducedFrom` via `PUT /concurrency/limits/:providerId`). Carried back to
+ * the banner so a FAILED restore surfaces an inline error next to the button
+ * (instead of silently re-enabling the button / showing the error far away).
+ */
+export type RestoreOutcome = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+export type LoadConcurrencyLimits = () => Promise<ConcurrencyLimitsResult>;
+export type GetConcurrencyLimit = (providerId: string) => Promise<ConcurrencyLimitResult>;
+export type SaveConcurrencyLimit = (
+ providerId: string,
+ limit: number,
+) => Promise<ConcurrencyLimitResult>;
+export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>;
+export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>;
+/** `GET /concurrency/cooldown/:providerId` — read the per-slot release cooldown. */
+export type GetConcurrencyCooldown = (providerId: string) => Promise<ConcurrencyCooldownResult>;
+/** `PUT /concurrency/cooldown/:providerId` — set the per-slot release cooldown (non-negative int). */
+export type SaveConcurrencyCooldown = (
+ providerId: string,
+ cooldownMs: number,
+) => Promise<ConcurrencyCooldownResult>;
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
new file mode 100644
index 0000000..6e6b770
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -0,0 +1,670 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ autoReduceNotices,
+ cooldownLabel,
+ DEFAULT_COOLDOWN_MS,
+ formatPauseDuration,
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ parseCooldownInput,
+ parseLimitInput,
+ pauseLabel,
+ providerFromModel,
+ providerOptions,
+ statusLabel,
+ summarizeLimits,
+ summarizeStatus,
+ viewAutoReduce,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./view-model";
+
+const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry => ({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 0,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ ...over,
+});
+
+// ── parseLimitInput ───────────────────────────────────────────────────────────
+
+describe("parseLimitInput", () => {
+ it("accepts positive integers", () => {
+ expect(parseLimitInput("4")).toBe(4);
+ expect(parseLimitInput(" 12 ")).toBe(12);
+ expect(parseLimitInput("1")).toBe(1);
+ });
+
+ it("rejects zero, negatives, non-integers, and garbage", () => {
+ expect(parseLimitInput("0")).toBeNull();
+ expect(parseLimitInput("-1")).toBeNull();
+ expect(parseLimitInput("4.5")).toBeNull();
+ expect(parseLimitInput("")).toBeNull();
+ expect(parseLimitInput(" ")).toBeNull();
+ expect(parseLimitInput("abc")).toBeNull();
+ expect(parseLimitInput("4abc")).toBeNull();
+ });
+});
+
+// ── parseCooldownInput (non-negative integer — 0 is valid, unlike the limit) ──
+
+describe("parseCooldownInput", () => {
+ it("accepts zero + positive integers", () => {
+ expect(parseCooldownInput("0")).toBe(0);
+ expect(parseCooldownInput("350")).toBe(350);
+ expect(parseCooldownInput(" 100 ")).toBe(100);
+ });
+
+ it("rejects negatives, non-integers, and garbage", () => {
+ expect(parseCooldownInput("-1")).toBeNull();
+ expect(parseCooldownInput("4.5")).toBeNull();
+ expect(parseCooldownInput("")).toBeNull();
+ expect(parseCooldownInput("abc")).toBeNull();
+ expect(parseCooldownInput("100ms")).toBeNull();
+ });
+});
+
+// ── cooldownLabel ─────────────────────────────────────────────────────────────
+
+describe("cooldownLabel", () => {
+ it("0 → off label", () => {
+ expect(cooldownLabel(0)).toBe("0ms (off)");
+ });
+ it("sub-second → ms", () => {
+ expect(cooldownLabel(350)).toBe("350ms");
+ expect(cooldownLabel(999)).toBe("999ms");
+ });
+ it("≥1s → seconds (trims trailing .0)", () => {
+ expect(cooldownLabel(1000)).toBe("1s");
+ expect(cooldownLabel(1500)).toBe("1.5s");
+ expect(cooldownLabel(60_000)).toBe("60s");
+ });
+});
+
+// ── providerFromModel / providerOptions ───────────────────────────────────────
+
+describe("providerFromModel", () => {
+ it("takes the part before the first slash", () => {
+ expect(providerFromModel("openai/gpt-4o")).toBe("openai");
+ expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat");
+ });
+ it("returns the whole string when there is no slash", () => {
+ expect(providerFromModel("umans")).toBe("umans");
+ });
+});
+
+describe("providerOptions", () => {
+ it("derives distinct provider ids from models, first-seen order", () => {
+ expect(
+ providerOptions(["openai/gpt-4o", "umans/umans-glm-5.2", "openai/gpt-4o-mini"], []),
+ ).toEqual(["openai", "umans"]);
+ });
+ it("unions with providers already carrying a configured limit", () => {
+ expect(providerOptions(["openai/gpt-4o"], [{ providerId: "anthropic", limit: 4 }])).toEqual([
+ "openai",
+ "anthropic",
+ ]);
+ });
+ it("does not duplicate a provider present in both models and limits", () => {
+ expect(providerOptions(["openai/gpt-4o"], [{ providerId: "openai", limit: 4 }])).toEqual([
+ "openai",
+ ]);
+ });
+ it("ignores models whose provider prefix is empty", () => {
+ expect(providerOptions(["/model-only", "umans/x"], [])).toEqual(["umans"]);
+ });
+ it("returns [] when there are no models and no limits", () => {
+ expect(providerOptions([], [])).toEqual([]);
+ });
+});
+
+// ── pauseLabel + formatPauseDuration ───────────────────────────────────────────
+
+describe("formatPauseDuration", () => {
+ it("formats seconds / minutes+seconds / hours+minutes", () => {
+ expect(formatPauseDuration(30_000)).toBe("30s");
+ expect(formatPauseDuration(65_000)).toBe("1m 05s");
+ expect(formatPauseDuration(3_660_000)).toBe("1h 01m");
+ });
+
+ it("non-positive → resuming", () => {
+ expect(formatPauseDuration(0)).toBe("resuming");
+ expect(formatPauseDuration(-5_000)).toBe("resuming");
+ });
+});
+
+describe("pauseLabel", () => {
+ it("null when not paused", () => {
+ expect(pauseLabel(false, undefined, 0)).toBeNull();
+ expect(pauseLabel(false, 10_000, 0)).toBeNull();
+ });
+
+ it("'paused' (bare) when paused without a usable timestamp", () => {
+ expect(pauseLabel(true, undefined, 0)).toBe("paused");
+ expect(pauseLabel(true, null, 0)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ });
+
+ it("countdown when paused with a future timestamp", () => {
+ const now = 1_000_000;
+ expect(pauseLabel(true, now + 30_000, now)).toBe("paused — resumes in 30s");
+ expect(pauseLabel(true, now + 65_000, now)).toBe("paused — resumes in 1m 05s");
+ });
+
+ it("'paused' (bare) when the timestamp is missing, non-finite, or expired", () => {
+ expect(pauseLabel(true, 0, 1_000)).toBe("paused");
+ expect(pauseLabel(true, 1_000, 2_000)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ // The countdown prefix only appears with a FUTURE timestamp:
+ expect(pauseLabel(true, 2_000, 1_000)).toBe("paused — resumes in 1s");
+ });
+});
+
+// ── viewConcurrencyStatus ──────────────────────────────────────────────────────
+
+describe("viewConcurrencyStatus", () => {
+ it("serving under capacity → success badge, in-flight label, no queue", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0);
+ expect(v.inFlightLabel).toBe("2/4");
+ expect(v.queuedLabel).toBe("no queue");
+ expect(v.pausedLabel).toBeNull();
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("at capacity with a queue → warning badge + busy (spinner)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0);
+ expect(v.inFlightLabel).toBe("4/4");
+ expect(v.queuedLabel).toBe("3 queued");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("idle (no in-flight) → neutral badge, not busy", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("neutral");
+ expect(v.busy).toBe(false);
+ expect(v.inFlightLabel).toBe("0/4");
+ });
+
+ it("paused → warning badge + pause countdown label", () => {
+ const now = 1_000_000;
+ const v = viewConcurrencyStatus(
+ status({ paused: true, pausedUntil: now + 30_000, inFlight: 4, limit: 4, queued: 3 }),
+ now,
+ );
+ expect(v.paused).toBe(true);
+ expect(v.pausedLabel).toBe("paused — resumes in 30s");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("at capacity but no queue → success (busy only when queuing)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("normalizes garbage counts to 0 and a malformed limit to 1", () => {
+ const v = viewConcurrencyStatus(
+ {
+ providerId: "x",
+ limit: -3,
+ inFlight: Number.NaN,
+ queued: "oops" as unknown as number,
+ paused: false,
+ cooldownMs: Number.NaN,
+ autoReduced: false,
+ },
+ 0,
+ );
+ expect(v.limit).toBe(1);
+ expect(v.inFlight).toBe(0);
+ expect(v.queued).toBe(0);
+ expect(v.inFlightLabel).toBe("0/1");
+ expect(v.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ });
+
+ it("viewConcurrencyStatuses maps a list preserving order", () => {
+ const views = viewConcurrencyStatuses(
+ [status({ providerId: "a" }), status({ providerId: "b" })],
+ 0,
+ );
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+
+ it("carries cooldownMs + label + autoReduced fields onto the view", () => {
+ const v = viewConcurrencyStatus(status({ cooldownMs: 1500 }), 0);
+ expect(v.cooldownMs).toBe(1500);
+ expect(v.cooldownLabel).toBe("1.5s");
+ expect(v.autoReduced).toBe(false);
+ expect(v.autoReducedFrom).toBeNull();
+ });
+
+ it("auto-reduced → warning badge (not busy) + autoReducedFrom carried", () => {
+ const v = viewConcurrencyStatus(
+ status({ limit: 3, autoReduced: true, autoReducedFrom: 4, inFlight: 0 }),
+ 0,
+ );
+ expect(v.autoReduced).toBe(true);
+ expect(v.autoReducedFrom).toBe(4);
+ expect(v.badge).toBe("warning");
+ // autoReduced alone does NOT flip busy (a reduced limit still admits agents).
+ expect(v.busy).toBe(false);
+ });
+});
+
+// ── statusLabel (the row's status badge word) ──────────────────────────────────
+
+describe("statusLabel", () => {
+ it("idle (no in-flight) → Idle", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0)),
+ ).toBe("Idle");
+ });
+
+ it("serving under capacity → Active", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0)),
+ ).toBe("Active");
+ });
+
+ it("at capacity with a queue → At capacity", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0)),
+ ).toBe("At capacity");
+ });
+
+ it("at capacity but no queue → Active (not At capacity)", () => {
+ expect(
+ statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0)),
+ ).toBe("Active");
+ });
+
+ it("paused → Paused (regardless of in-flight/queue)", () => {
+ expect(
+ statusLabel(
+ viewConcurrencyStatus(status({ paused: true, inFlight: 4, limit: 4, queued: 3 }), 0),
+ ),
+ ).toBe("Paused");
+ });
+});
+
+// ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ───────────
+
+describe("viewAutoReduce", () => {
+ it("returns null when not auto-reduced", () => {
+ expect(viewAutoReduce(status({ autoReduced: false }))).toBeNull();
+ });
+
+ it("uses the backend notice verbatim + carries from/current limits", () => {
+ const notice = viewAutoReduce(
+ status({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ );
+ expect(notice).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ });
+
+ it("synthesizes a fallback notice when the backend notice is absent/empty", () => {
+ expect(viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4 }))).toEqual({
+ providerId: "umans",
+ message: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ fromLimit: 4,
+ currentLimit: 3,
+ });
+ expect(
+ viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4, notice: "" })),
+ ).not.toBeNull();
+ });
+
+ it("falls back to currentLimit+1 when autoReducedFrom is missing/garbage", () => {
+ const notice = viewAutoReduce(status({ limit: 3, autoReduced: true }));
+ expect(notice?.fromLimit).toBe(4); // 3 + 1
+ });
+});
+
+describe("autoReduceNotices", () => {
+ it("collects one banner per auto-reduced provider (input order), empty when none", () => {
+ expect(autoReduceNotices([status({ providerId: "a" })])).toEqual([]);
+ const out = autoReduceNotices([
+ status({ providerId: "a", autoReduced: true, autoReducedFrom: 4, limit: 3 }),
+ status({ providerId: "b" }),
+ status({ providerId: "c", autoReduced: true, autoReducedFrom: 2, limit: 1 }),
+ ]);
+ expect(out.map((n) => n.providerId)).toEqual(["a", "c"]);
+ expect(out[1]?.fromLimit).toBe(2);
+ });
+});
+
+// ── viewConcurrencyLimit ───────────────────────────────────────────────────────
+
+describe("viewConcurrencyLimit", () => {
+ it("passes through id + normalizes the limit", () => {
+ const v = viewConcurrencyLimit({ providerId: "umans", limit: 4 });
+ expect(v.providerId).toBe("umans");
+ expect(v.limit).toBe(4);
+ });
+
+ it("clamps a malformed limit to 1", () => {
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 0 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: -2 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 2.9 }).limit).toBe(2);
+ });
+
+ it("viewConcurrencyLimits maps a list preserving order", () => {
+ const views = viewConcurrencyLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]);
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+});
+
+// ── summarizeLimits / summarizeStatus ──────────────────────────────────────────
+
+describe("summarizeLimits", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeLimits([])).toBe("No limits configured");
+ });
+ it("counts limits (singular/plural)", () => {
+ expect(summarizeLimits([{ providerId: "a", limit: 1 }])).toBe("1 limit configured");
+ expect(
+ summarizeLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]),
+ ).toBe("2 limits configured");
+ });
+});
+
+describe("summarizeStatus", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeStatus([], 0)).toBe("No limits configured");
+ });
+ it("aggregates providers + in-flight totals", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 2 }),
+ status({ providerId: "b", limit: 6, inFlight: 3 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/10 in flight");
+ });
+ it("includes queued + paused fragments only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 4, queued: 2 }),
+ status({
+ providerId: "b",
+ limit: 4,
+ inFlight: 1,
+ queued: 0,
+ paused: true,
+ pausedUntil: 1000,
+ }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/8 in flight · 2 queued · 1 paused");
+ });
+ it("singular provider", () => {
+ expect(summarizeStatus([status({ providerId: "a", limit: 4, inFlight: 1 })], 0)).toBe(
+ "1 provider · 1/4 in flight",
+ );
+ });
+ it("includes an auto-reduced fragment only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 3, inFlight: 1, autoReduced: true, autoReducedFrom: 4 }),
+ status({ providerId: "b", limit: 4, inFlight: 1 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 2/7 in flight · 1 auto-reduced");
+ });
+});
+
+// ── Network-seam normalizers ───────────────────────────────────────────────────
+
+describe("normalizeConcurrencyLimits", () => {
+ it("coerces a well-formed body", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ });
+
+ it("non-array / missing limits → []", () => {
+ expect(normalizeConcurrencyLimits({})).toEqual([]);
+ expect(normalizeConcurrencyLimits({ limits: "nope" })).toEqual([]);
+ expect(normalizeConcurrencyLimits(null)).toEqual([]);
+ expect(normalizeConcurrencyLimits(undefined)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps limits", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "", limit: 9 },
+ { providerId: 123, limit: 1 },
+ { limit: 2 },
+ { providerId: "anthropic", limit: -5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "anthropic", limit: 1 },
+ ]);
+ });
+});
+
+describe("normalizeConcurrencyLimit", () => {
+ it("coerces a well-formed single response", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "umans", limit: 4 })).toEqual({
+ providerId: "umans",
+ limit: 4,
+ });
+ });
+ it("null when the provider id is missing/non-string", () => {
+ expect(normalizeConcurrencyLimit({ limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit({ providerId: "", limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit(null)).toBeNull();
+ });
+ it("clamps a malformed limit to 1", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "x", limit: 0 })).toEqual({
+ providerId: "x",
+ limit: 1,
+ });
+ });
+});
+
+describe("normalizeConcurrencyStatus", () => {
+ it("coerces a well-formed body, preserving pausedUntil only when present", () => {
+ const now = Date.now();
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ },
+ ],
+ });
+ expect(providers).toHaveLength(2);
+ const [first, second] = providers;
+ expect(first).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ });
+ expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
+ expect(second).toEqual({
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ cooldownMs: 350,
+ autoReduced: false,
+ });
+ });
+
+ it("non-array / missing providers → []", () => {
+ expect(normalizeConcurrencyStatus({})).toEqual([]);
+ expect(normalizeConcurrencyStatus({ providers: 42 })).toEqual([]);
+ expect(normalizeConcurrencyStatus(null)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps counts", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ { providerId: "", inFlight: 1 },
+ { limit: 2 },
+ { providerId: 9, inFlight: 0 },
+ { providerId: "x", limit: -1, inFlight: "bad", queued: null, paused: "yes" },
+ ],
+ });
+ expect(providers).toEqual([
+ {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
+ {
+ providerId: "x",
+ limit: 1,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ },
+ ]);
+ });
+
+ it("omits pausedUntil when it is not a finite number", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "a", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: "x" },
+ { providerId: "b", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: null },
+ ],
+ });
+ for (const p of providers) expect("pausedUntil" in p).toBe(false);
+ });
+
+ it("coerces cooldownMs (default 350) + carries auto-reduce fields only when true", () => {
+ const [reduced, healthy] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "umans",
+ limit: 3,
+ inFlight: 1,
+ queued: 0,
+ paused: false,
+ cooldownMs: 500,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "auto-reduced to 3 after a 429.",
+ },
+ { providerId: "openai", limit: 4, inFlight: 0, queued: 0, paused: false },
+ ],
+ });
+ expect(reduced?.cooldownMs).toBe(500);
+ expect(reduced?.autoReduced).toBe(true);
+ expect(reduced?.autoReducedFrom).toBe(4);
+ expect(reduced?.notice).toBe("auto-reduced to 3 after a 429.");
+ // Healthy entry: cooldownMs defaults to 350 when absent; auto-reduce fields
+ // are NOT present (they are only included when autoReduced===true).
+ expect(healthy?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(healthy?.autoReduced).toBe(false);
+ expect(healthy && "autoReducedFrom" in healthy).toBe(false);
+ expect(healthy && "notice" in healthy).toBe(false);
+ });
+
+ it("drops autoReducedFrom/notice when autoReduced is false (even if present in JSON)", () => {
+ const [p] = normalizeConcurrencyStatus({
+ providers: [
+ {
+ providerId: "x",
+ limit: 4,
+ inFlight: 0,
+ queued: 0,
+ paused: false,
+ autoReduced: false,
+ autoReducedFrom: 9,
+ notice: "stale",
+ },
+ ],
+ });
+ expect(p?.autoReduced).toBe(false);
+ expect(p && "autoReducedFrom" in p).toBe(false);
+ expect(p && "notice" in p).toBe(false);
+ });
+});
+
+// ── normalizeConcurrencyCooldown ───────────────────────────────────────────────
+
+describe("normalizeConcurrencyCooldown", () => {
+ it("coerces a well-formed body", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "umans", cooldownMs: 500 })).toEqual({
+ providerId: "umans",
+ cooldownMs: 500,
+ });
+ });
+
+ it("defaults a malformed/absent cooldownMs to 350", () => {
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: -1 })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ expect(normalizeConcurrencyCooldown({ providerId: "x" })?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS);
+ expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: "fast" })?.cooldownMs).toBe(
+ DEFAULT_COOLDOWN_MS,
+ );
+ });
+
+ it("returns null for a missing/malformed providerId", () => {
+ expect(normalizeConcurrencyCooldown({ cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown({ providerId: "", cooldownMs: 350 })).toBeNull();
+ expect(normalizeConcurrencyCooldown(null)).toBeNull();
+ expect(normalizeConcurrencyCooldown({})).toBeNull();
+ });
+});
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
new file mode 100644
index 0000000..8a37198
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.ts
@@ -0,0 +1,487 @@
+import type {
+ ConcurrencyCooldownResponse,
+ ConcurrencyStatusEntry,
+} from "@dispatch/transport-contract";
+import type { ConcurrencyLimitEntry } from "./types";
+
+/**
+ * Pure view-models for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to
+ * display shapes (badges, "2/4" in-flight labels, pause countdowns, cooldown
+ * labels, auto-reduce banners, summaries), holds the limit/cooldown-input
+ * parsing, and the network-seam normalizers the composition root coerces the
+ * untyped JSON with.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A configured limit row shaped for display. */
+export interface ConcurrencyLimitView {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+/**
+ * A live status row shaped for display. Carries the raw counts plus pre-computed
+ * labels so the template stays thin.
+ */
+export interface ConcurrencyStatusView {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ /** "2/4" — in-flight slots held vs the cap. */
+ readonly inFlightLabel: string;
+ /** "1 queued" / "no queue". */
+ readonly queuedLabel: string;
+ /** A pause label when paused, e.g. "paused — resumes in 30s"; null otherwise. */
+ readonly pausedLabel: string | null;
+ readonly badge: Badge;
+ /** True when paused or at capacity (show a spinner). */
+ readonly busy: boolean;
+ /** Per-slot release cooldown in ms (defensive default 350 on garbage). */
+ readonly cooldownMs: number;
+ /** "350ms" / "1.2s" / "0ms (off)" — display label for the cooldown. */
+ readonly cooldownLabel: string;
+ /** Whether the limit was auto-reduced by a 429 (one-way; user restores manually). */
+ readonly autoReduced: boolean;
+ /** The original limit before auto-reduction; null when not auto-reduced. */
+ readonly autoReducedFrom: number | null;
+}
+
+/**
+ * A view-model for the auto-reduce banner — derived from a status entry whose
+ * `autoReduced` is `true`. `message` is the backend's `notice` when present, else
+ * a synthesized fallback. `viewAutoReduce` returns this (or null) so the banner
+ * section renders without reaching into the raw entry.
+ */
+export interface AutoReduceNotice {
+ readonly providerId: string;
+ readonly message: string;
+ /** The original limit before reduction — the value "Restore to N" PUTs. */
+ readonly fromLimit: number;
+ /** The current (reduced) limit. */
+ readonly currentLimit: number;
+}
+
+// ── Limit input parsing ───────────────────────────────────────────────────────
+
+/**
+ * Parse a raw limit input into a positive integer, or `null` when it is not a
+ * valid positive integer. Accepts "4" → 4; rejects "0", "-1", "4.5", "", "abc".
+ * Drives the Add/Save button's disabled state so an invalid value never reaches
+ * the backend (the backend is still the authority — it 400s a non-positive body).
+ */
+export function parseLimitInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 1 ? n : null;
+}
+
+/**
+ * Coerce an untrusted limit value into a positive integer (default 1). Used when
+ * normalizing backend responses so a malformed `limit` can never be 0/negative.
+ */
+export function normalizeLimit(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 1;
+ const int = Math.floor(n);
+ return int >= 1 ? int : 1;
+}
+
+// ── Cooldown input parsing ────────────────────────────────────────────────────
+//
+// The per-slot release cooldown (ms) is a NON-NEGATIVE integer (0 = no cooldown,
+// instant re-admission) — unlike the limit, 0 is a VALID value. The default is
+// 350ms (the backend's server default when a limit is set but no explicit
+// cooldown was configured). It is configurable + persisted per provider via
+// `PUT /concurrency/cooldown/:providerId`.
+
+/** The server's default cooldown (ms) — used when none is explicitly set. */
+export const DEFAULT_COOLDOWN_MS = 350;
+
+/**
+ * Parse a raw cooldown input into a non-negative integer, or `null` when it is
+ * not valid. Accepts "0" → 0, "350" → 350; rejects "-1", "4.5", "", "abc".
+ * Drives the cooldown Save button's disabled state so an invalid value never
+ * reaches the backend (the backend 400s a non-negative-integer body).
+ */
+export function parseCooldownInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 0 ? n : null;
+}
+
+/**
+ * Coerce an untrusted cooldown value into a non-negative integer (default
+ * {@link DEFAULT_COOLDOWN_MS}). Used when normalizing backend responses so a
+ * malformed `cooldownMs` can never be negative/non-finite.
+ */
+export function normalizeCooldown(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_COOLDOWN_MS;
+ const int = Math.floor(n);
+ return int >= 0 ? int : DEFAULT_COOLDOWN_MS;
+}
+
+/**
+ * Format a cooldown (ms) as a short display label:
+ * 0 → "0ms (off)" · <1000 → "350ms" · ≥1000 → "1.2s" (trailing ".0" trimmed).
+ */
+export function cooldownLabel(ms: number): string {
+ if (ms <= 0) return "0ms (off)";
+ if (ms < 1000) return `${ms}ms`;
+ const secs = ms / 1000;
+ const fixed = secs.toFixed(1);
+ return `${fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed}s`;
+}
+
+// ── Provider options (the Add-form dropdown) ───────────────────────────────────
+//
+// A concurrency `providerId` is the credential name that prefixes a model name
+// (`<provider>/<model>` — the same key the model picker groups by). The dropdown
+// is the UNION of providers discoverable from the available models AND providers
+// already carrying a configured limit (so a limit set out-of-band but whose
+// model list is empty still appears), in first-seen order. Models are the
+// authority; a provider with models but no limit is still selectable (Add sets it).
+
+/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */
+export function providerFromModel(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? full : full.slice(0, i);
+}
+
+/** Distinct provider ids to offer in the Add dropdown, first-seen order. */
+export function providerOptions(
+ models: readonly string[],
+ limits: readonly ConcurrencyLimitEntry[],
+): string[] {
+ const seen = new Set<string>();
+ const out: string[] = [];
+ const add = (p: string): void => {
+ if (p !== "" && !seen.has(p)) {
+ seen.add(p);
+ out.push(p);
+ }
+ };
+ for (const m of models) add(providerFromModel(m));
+ for (const l of limits) add(l.providerId);
+ return out;
+}
+
+// ── Status → display view ──────────────────────────────────────────────────────
+
+const NO_LIMITS = "No limits configured";
+
+/**
+ * Format a remaining-ms delta as a short pause countdown: "30s", "1m 05s",
+ * "resuming" (≤ 0). Pure via the injected `remainingMs`. The component recomputes
+ * this on each status poll (every ~2s) — a 1s ticking timer is optional.
+ */
+export function formatPauseDuration(remainingMs: number): string {
+ if (remainingMs <= 0) return "resuming";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * The pause label for a status entry, or `null` when not paused. When paused with
+ * a future `pausedUntil`, shows "paused — resumes in 30s"; when paused without a
+ * usable timestamp, shows "paused". Pure via the injectable `now`.
+ */
+export function pauseLabel(
+ paused: boolean,
+ pausedUntil: number | null | undefined,
+ now: number = Date.now(),
+): string | null {
+ if (!paused) return null;
+ if (typeof pausedUntil === "number" && Number.isFinite(pausedUntil)) {
+ const remaining = pausedUntil - now;
+ if (remaining > 0) return `paused — resumes in ${formatPauseDuration(remaining)}`;
+ }
+ // Paused without a usable future timestamp (missing, non-finite, or already
+ // expired): the next status poll will clear `paused`. Show "paused" meanwhile.
+ return "paused";
+}
+
+/**
+ * Build a display view for a status entry. `now` is injectable for tests
+ * (defaults to `Date.now()`); the composition-root component passes nothing in
+ * production (it recomputes on each poll).
+ *
+ * `autoReduced` does NOT flip `busy` (a reduced limit still admits agents; it is
+ * a degraded-but-active state surfaced via the banner, not a spinner) — it only
+ * nudges the badge to `warning` so the row signals attention.
+ */
+export function viewConcurrencyStatus(
+ entry: ConcurrencyStatusEntry,
+ now: number = Date.now(),
+): ConcurrencyStatusView {
+ const limit = normalizeLimit(entry.limit);
+ const inFlight = clampCount(entry.inFlight);
+ const queued = clampCount(entry.queued);
+ const paused = entry.paused === true;
+ const autoReduced = entry.autoReduced === true;
+ const atCapacity = inFlight >= limit;
+ let badge: Badge;
+ if (paused) badge = "warning";
+ else if (autoReduced) badge = "warning";
+ else if (atCapacity && queued > 0) badge = "warning";
+ else if (inFlight > 0) badge = "success";
+ else badge = "neutral";
+ const cooldownMs = normalizeCooldown(entry.cooldownMs);
+ const autoReducedFrom =
+ autoReduced &&
+ typeof entry.autoReducedFrom === "number" &&
+ Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : null;
+ return {
+ providerId: entry.providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ inFlightLabel: `${inFlight}/${limit}`,
+ queuedLabel: queued === 0 ? "no queue" : `${queued} queued`,
+ pausedLabel: pauseLabel(paused, entry.pausedUntil, now),
+ badge,
+ busy: paused || (atCapacity && queued > 0),
+ cooldownMs,
+ cooldownLabel: cooldownLabel(cooldownMs),
+ autoReduced,
+ autoReducedFrom,
+ };
+}
+
+/**
+ * A short status word for a status view, for the row's status badge:
+ * "Paused" · "At capacity" (in-flight at the cap with a queue) · "Active"
+ * (in-flight > 0) · "Idle". Mirrors the badge-text branching so the template
+ * holds no branching logic.
+ */
+export function statusLabel(view: ConcurrencyStatusView): string {
+ if (view.paused) return "Paused";
+ if (view.inFlight >= view.limit && view.queued > 0) return "At capacity";
+ if (view.inFlight > 0) return "Active";
+ return "Idle";
+}
+
+/**
+ * The auto-reduce banner view for a status entry, or `null` when it is not
+ * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present
+ * + non-empty); otherwise a synthesized fallback is built from
+ * `autoReducedFrom` → `limit`. `fromLimit` is the value "Restore to N" PUTs back.
+ */
+export function viewAutoReduce(entry: ConcurrencyStatusEntry): AutoReduceNotice | null {
+ if (entry.autoReduced !== true) return null;
+ const currentLimit = normalizeLimit(entry.limit);
+ const fromLimit =
+ typeof entry.autoReducedFrom === "number" && Number.isFinite(entry.autoReducedFrom)
+ ? normalizeLimit(entry.autoReducedFrom)
+ : currentLimit + 1;
+ const notice =
+ typeof entry.notice === "string" && entry.notice.length > 0
+ ? entry.notice
+ : `Concurrency limit auto-reduced to ${currentLimit} after a 429 — restore manually when ready.`;
+ return {
+ providerId: entry.providerId,
+ message: notice,
+ fromLimit,
+ currentLimit,
+ };
+}
+
+/**
+ * All auto-reduce banners across a status list (one per auto-reduced provider),
+ * in input order. Empty when none are auto-reduced.
+ */
+export function autoReduceNotices(
+ entries: readonly ConcurrencyStatusEntry[],
+): readonly AutoReduceNotice[] {
+ const out: AutoReduceNotice[] = [];
+ for (const e of entries) {
+ const n = viewAutoReduce(e);
+ if (n !== null) out.push(n);
+ }
+ return out;
+}
+
+export function viewConcurrencyStatuses(
+ entries: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): readonly ConcurrencyStatusView[] {
+ return entries.map((e) => viewConcurrencyStatus(e, now));
+}
+
+/** A display view for a configured limit entry. */
+export function viewConcurrencyLimit(entry: ConcurrencyLimitEntry): ConcurrencyLimitView {
+ return { providerId: entry.providerId, limit: normalizeLimit(entry.limit) };
+}
+
+export function viewConcurrencyLimits(
+ entries: readonly ConcurrencyLimitEntry[],
+): readonly ConcurrencyLimitView[] {
+ return entries.map(viewConcurrencyLimit);
+}
+
+// ── Summaries ──────────────────────────────────────────────────────────────────
+
+/** A one-line summary of the configured limits list, e.g. "2 limits configured". */
+export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): string {
+ if (limits.length === 0) return NO_LIMITS;
+ return `${limits.length} limit${limits.length === 1 ? "" : "s"} configured`;
+}
+
+/**
+ * A one-line summary of the live status, e.g.
+ * "2 providers · 6/10 in flight · 1 queued · 1 paused · 1 auto-reduced". Only the
+ * queued / paused / auto-reduced fragments appear when non-zero.
+ */
+export function summarizeStatus(
+ providers: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): string {
+ if (providers.length === 0) return NO_LIMITS;
+ let inFlight = 0;
+ let limitTotal = 0;
+ let queued = 0;
+ let paused = 0;
+ let autoReduced = 0;
+ for (const p of providers) {
+ const limit = normalizeLimit(p.limit);
+ inFlight += clampCount(p.inFlight);
+ limitTotal += limit;
+ queued += clampCount(p.queued);
+ if (p.paused === true) paused += 1;
+ if (p.autoReduced === true) autoReduced += 1;
+ }
+ const parts: string[] = [];
+ parts.push(
+ `${providers.length} provider${providers.length === 1 ? "" : "s"}`,
+ `${inFlight}/${limitTotal} in flight`,
+ );
+ if (queued > 0) parts.push(`${queued} queued`);
+ if (paused > 0) parts.push(`${paused} paused`);
+ if (autoReduced > 0) parts.push(`${autoReduced} auto-reduced`);
+ // Touch `now` so the summary recomputes alongside the per-row pause countdown.
+ void now;
+ return parts.join(" · ");
+}
+
+// ── Network-seam normalization (pure; called by the composition root) ───────────
+//
+// The concurrency responses are untyped JSON at runtime. The store coerces each
+// defensively HERE (pure + tested) — a malformed/partial backend value (e.g. the
+// extension returning `{}`) can never crash the renderer. Mirrors the
+// `normalizeHeartbeatConfig` / inline `Array.isArray(data.servers)` guards.
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+/** Coerce a non-negative count field to a non-negative integer (0 on garbage). */
+function clampCount(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 0;
+ const int = Math.floor(n);
+ return int >= 0 ? int : 0;
+}
+
+/** Coerce an untrusted `GET /concurrency/limits` body into a typed limit list. */
+export function normalizeConcurrencyLimits(data: unknown): readonly ConcurrencyLimitEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.limits)) return [];
+ const limits = data.limits as readonly unknown[];
+ return limits
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r) => ({
+ providerId: asString(r.providerId) ?? "",
+ limit: normalizeLimit(r.limit),
+ }))
+ .filter((r) => r.providerId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT /concurrency/limits/:id` body into a limit, or null. */
+export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, limit: normalizeLimit(data.limit) };
+}
+
+/**
+ * Coerce an untrusted `GET /concurrency/status` body into a typed status list.
+ * `pausedUntil` is included only when it is a finite number (it is absent when
+ * not paused). `cooldownMs` (default 350) + `autoReduced` are always coerced;
+ * `autoReducedFrom` + `notice` are included only when `autoReduced` is true (and
+ * well-formed), mirroring the backend's "present only when auto-reduced" contract.
+ */
+export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.providers)) return [];
+ const providers = data.providers as readonly unknown[];
+ return providers
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r): ConcurrencyStatusEntry => {
+ const providerId = asString(r.providerId) ?? "";
+ const limit = normalizeLimit(r.limit);
+ const inFlight = clampCount(r.inFlight);
+ const queued = clampCount(r.queued);
+ const paused = r.paused === true;
+ const cooldownMs = normalizeCooldown(r.cooldownMs);
+ const autoReduced = r.autoReduced === true;
+ // Build immutably (the contract fields are readonly): start with the always-
+ // present fields, then layer the optional `pausedUntil` (finite number only)
+ // + the auto-reduce-only `autoReducedFrom`/`notice` (present only when true).
+ let entry: ConcurrencyStatusEntry = {
+ providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ cooldownMs,
+ autoReduced,
+ };
+ if (typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)) {
+ entry = { ...entry, pausedUntil: r.pausedUntil };
+ }
+ if (autoReduced) {
+ // Accumulate the auto-reduce-only optionals into a plain record (the
+ // contract fields are readonly, so we can't mutate a typed partial —
+ // collect then spread into a fresh entry).
+ const patch: { autoReducedFrom?: number; notice?: string } = {};
+ if (typeof r.autoReducedFrom === "number" && Number.isFinite(r.autoReducedFrom)) {
+ patch.autoReducedFrom = normalizeLimit(r.autoReducedFrom);
+ }
+ if (typeof r.notice === "string" && r.notice.length > 0) {
+ patch.notice = r.notice;
+ }
+ if (patch.autoReducedFrom !== undefined || patch.notice !== undefined) {
+ entry = { ...entry, ...patch };
+ }
+ }
+ return entry;
+ })
+ .filter((r) => r.providerId !== "");
+}
+
+/**
+ * Coerce an untrusted `GET`/`PUT /concurrency/cooldown/:providerId` body into a
+ * typed cooldown response, or `null` when it is malformed (missing/malformed
+ * `providerId` or `cooldownMs`). The composition root surfaces a 404/400/503 as
+ * `ok: false` separately; this only defends the success body.
+ */
+export function normalizeConcurrencyCooldown(data: unknown): ConcurrencyCooldownResponse | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, cooldownMs: normalizeCooldown(data.cooldownMs) };
+}
diff --git a/src/features/concurrency/ui/AutoReduceBanner.svelte b/src/features/concurrency/ui/AutoReduceBanner.svelte
new file mode 100644
index 0000000..132ebc7
--- /dev/null
+++ b/src/features/concurrency/ui/AutoReduceBanner.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import type { AutoReduceNotice } from "../logic/view-model";
+ import type { RestoreOutcome } from "../logic/types";
+
+ let {
+ notice,
+ onRestore,
+ onDismiss,
+ }: {
+ /** The auto-reduce banner view (providerId + message + from/current limit). */
+ notice: AutoReduceNotice;
+ /**
+ * "Restore to N" — PUT the limit back to `fromLimit`. Returns the outcome so
+ * a FAILED restore surfaces an inline error here (the banner owns its error
+ * display; the parent only refreshes on success).
+ */
+ onRestore: (providerId: string, limit: number) => Promise<RestoreOutcome>;
+ /** Hide this banner locally (persists hidden while autoReduced stays true). */
+ onDismiss: (providerId: string) => void;
+ } = $props();
+
+ let restoring = $state(false);
+ /** Inline restore error (e.g. "Concurrency service not available"); cleared on retry. */
+ let error = $state<string | null>(null);
+
+ async function handleRestore(): Promise<void> {
+ restoring = true;
+ error = null;
+ // The parent PUTs the limit + refreshes status on success; the banner clears
+ // once the next poll shows autoReduced===false. On failure the outcome is
+ // bubbled back here so the error shows inline next to the button.
+ const result = await onRestore(notice.providerId, notice.fromLimit);
+ restoring = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ }
+</script>
+
+<div
+ class="alert alert-warning flex flex-col gap-2 py-2 text-xs"
+ role="status"
+ data-testid={`auto-reduce-banner-${notice.providerId}`}
+>
+ <div class="flex items-start gap-2">
+ <span class="shrink-0">⚠</span>
+ <div class="flex-1">
+ <p>{notice.message}</p>
+ <p class="opacity-70">
+ Was {notice.fromLimit}, now {notice.currentLimit}.
+ </p>
+ </div>
+ <div class="flex shrink-0 items-center gap-1">
+ <!-- The "Restore to N" text stays visible while loading (only the spinner is
+ prepended) so the button keeps its accessible name during the PUT — a
+ spinner-only button loses its name for screen-reader users. -->
+ <button
+ type="button"
+ class="btn btn-warning btn-xs gap-1"
+ disabled={restoring}
+ onclick={handleRestore}
+ >
+ {#if restoring}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ Restore to {notice.fromLimit}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ aria-label={`Dismiss auto-reduce notice for ${notice.providerId}`}
+ onclick={() => onDismiss(notice.providerId)}
+ >
+ ✕
+ </button>
+ </div>
+ </div>
+ {#if error}
+ <p class="font-mono text-error" data-testid={`restore-error-${notice.providerId}`}>{error}</p>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
new file mode 100644
index 0000000..bf06ac0
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -0,0 +1,204 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import {
+ DEFAULT_COOLDOWN_MS,
+ parseCooldownInput,
+ parseLimitInput,
+ statusLabel,
+ type Badge,
+ type ConcurrencyLimitView,
+ type ConcurrencyStatusView,
+ } from "../logic/view-model";
+ import type {
+ DeleteConcurrencyLimit,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+
+ let {
+ limit,
+ status,
+ save,
+ saveCooldown,
+ remove,
+ }: {
+ /** The configured limit row (providerId + current limit). */
+ limit: ConcurrencyLimitView;
+ /** The provider's live status view (in-flight/queue/badge), or null when no
+ * status entry exists yet. Drives the status line + seeds the cooldown input. */
+ status: ConcurrencyStatusView | null;
+ save: SaveConcurrencyLimit;
+ saveCooldown: SaveConcurrencyCooldown;
+ remove: DeleteConcurrencyLimit;
+ } = $props();
+
+ // The badge→color map (presentational). Mirrors the old status-card mapping.
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ // The cooldown input seed: the live cooldown when a status entry exists, else
+ // the server default (350).
+ const cooldownMs = $derived(status?.cooldownMs ?? DEFAULT_COOLDOWN_MS);
+
+ // Inline-edit state for the limit + cooldown inputs. Each is seeded from its
+ // canonical value, but only while untouched — so a save echo / status-poll
+ // refresh re-syncs without clobbering an in-flight edit. Mirrors the
+ // ChatLimitField seed pattern (avoids reading the prop in the $state init).
+ let limitDraft = $state("");
+ let lastLimitSeed = $state("");
+ let cooldownDraft = $state("");
+ let lastCooldownSeed = $state("");
+ let saving = $state(false);
+ let removing = $state(false);
+ let error = $state<string | null>(null);
+ /** Brief "Saved" confirmation after a successful save; cleared on edit. */
+ let justSaved = $state(false);
+
+ $effect(() => {
+ const incomingLimit = String(limit.limit);
+ const incomingCooldown = String(cooldownMs);
+ untrack(() => {
+ if (limitDraft === lastLimitSeed) limitDraft = incomingLimit;
+ lastLimitSeed = incomingLimit;
+ if (cooldownDraft === lastCooldownSeed) cooldownDraft = incomingCooldown;
+ lastCooldownSeed = incomingCooldown;
+ });
+ });
+
+ const parsedLimit = $derived(parseLimitInput(limitDraft));
+ const parsedCooldown = $derived(parseCooldownInput(cooldownDraft));
+ const dirtyLimit = $derived(parsedLimit !== null && parsedLimit !== limit.limit);
+ const dirtyCooldown = $derived(parsedCooldown !== null && parsedCooldown !== cooldownMs);
+ const dirty = $derived(dirtyLimit || dirtyCooldown);
+
+ // Clear the "Saved" hint + any error as soon as the user edits either field.
+ function onInput(): void {
+ justSaved = false;
+ error = null;
+ }
+
+ // "Set" saves whichever field is dirty: the limit first (PUT
+ // /concurrency/limits/:id), then the cooldown (PUT /concurrency/cooldown/:id).
+ // Stops + surfaces an inline error on the first failure.
+ async function handleSet(): Promise<void> {
+ if (!dirty || saving || removing) return;
+ saving = true;
+ error = null;
+ try {
+ if (dirtyLimit && parsedLimit !== null) {
+ const r = await save(limit.providerId, parsedLimit);
+ if (!r.ok) {
+ error = r.error;
+ return;
+ }
+ // Reflect the echoed limit back immediately (the prop re-asserts it via
+ // the seed effect once the parent reloads).
+ limitDraft = String(r.limit);
+ lastLimitSeed = limitDraft;
+ }
+ if (dirtyCooldown && parsedCooldown !== null) {
+ const r = await saveCooldown(limit.providerId, parsedCooldown);
+ if (!r.ok) {
+ error = r.error;
+ return;
+ }
+ cooldownDraft = String(r.cooldownMs);
+ lastCooldownSeed = cooldownDraft;
+ }
+ justSaved = true;
+ } finally {
+ saving = false;
+ }
+ }
+
+ async function handleRemove(): Promise<void> {
+ removing = true;
+ error = null;
+ const result = await remove(limit.providerId);
+ removing = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ // On success the parent drops this row (re-loaded limits list).
+ }
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <!-- Line 1: provider + limit + cooldown + Set + ✕ (all on one line — nowrap so
+ the buttons never wrap; tight gap + narrow inputs keep the provider name
+ visible; the provider shrinks via flex-1 + min-w-0). -->
+ <div class="flex flex-nowrap items-center gap-1">
+ <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId}
+ >{limit.providerId}</span
+ >
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-12 min-w-0 font-mono"
+ aria-label={`Concurrency limit for ${limit.providerId}`}
+ bind:value={limitDraft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-14 min-w-0 font-mono"
+ aria-label={`Release cooldown (ms) for ${limit.providerId}`}
+ bind:value={cooldownDraft}
+ oninput={onInput}
+ disabled={saving || removing}
+ />
+ <span class="shrink-0 text-[10px] opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs shrink-0"
+ aria-label={`Set concurrency for ${limit.providerId}`}
+ disabled={!dirty || saving || removing}
+ onclick={handleSet}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs shrink-0 text-error"
+ aria-label={`Remove concurrency limit for ${limit.providerId}`}
+ disabled={saving || removing}
+ onclick={handleRemove}
+ >
+ {#if removing}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+
+ <!-- Line 2: in-flight count (left) + status badge (right). Hidden until the
+ first status poll for this provider lands. -->
+ {#if status !== null}
+ <div class="flex items-center justify-between gap-2 text-xs opacity-70">
+ <span title="In-flight slots held vs cap">{status.inFlightLabel} in flight</span>
+ <span class="badge badge-sm {badgeClass[status.badge]} gap-1">
+ {#if status.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {statusLabel(status)}
+ </span>
+ </div>
+ {/if}
+
+ {#if error}
+ <span class="font-mono text-xs text-error">{error}</span>
+ {:else if justSaved && !dirty}
+ <span class="text-xs text-success">Saved.</span>
+ {/if}
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
new file mode 100644
index 0000000..aadb8d1
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -0,0 +1,433 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+ import {
+ autoReduceNotices,
+ DEFAULT_COOLDOWN_MS,
+ parseCooldownInput,
+ parseLimitInput,
+ providerOptions,
+ summarizeLimits,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ type ConcurrencyStatusView,
+ } from "../logic/view-model";
+ import type {
+ ConcurrencyLimitEntry,
+ DeleteConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ RestoreOutcome,
+ SaveConcurrencyCooldown,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+ import AutoReduceBanner from "./AutoReduceBanner.svelte";
+ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
+
+ let {
+ models,
+ loadLimits,
+ saveLimit,
+ deleteLimit,
+ loadStatus,
+ saveCooldown,
+ }: {
+ /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */
+ models: readonly string[];
+ loadLimits: LoadConcurrencyLimits;
+ saveLimit: SaveConcurrencyLimit;
+ deleteLimit: DeleteConcurrencyLimit;
+ loadStatus: LoadConcurrencyStatus;
+ saveCooldown: SaveConcurrencyCooldown;
+ } = $props();
+
+ // ── Limits (config: list / add / update / remove) ────────────────────────────
+ let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
+ let limitsError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedLimits = $state(false);
+ /** Re-entrancy guard for background/silent refreshes (no UI — prevents
+ * overlapping fetches). The refresh is near-instant, so a visible loading
+ * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the
+ * heartbeat runs list). */
+ let limitsInFlight = false;
+
+ // Add-row state. The provider id is chosen from a dropdown of known providers
+ // (derived from the available models + any already-configured limit providers).
+ // The row is revealed by the "Add" button; "Set" saves it, ✕ cancels.
+ let addOpen = $state(false);
+ let newProviderId = $state("");
+ let newLimitInput = $state("");
+ let newCooldownInput = $state("");
+ let adding = $state(false);
+ let addError = $state<string | null>(null);
+
+ const providerOpts = $derived(providerOptions(models, limits));
+ const limitViews = $derived(viewConcurrencyLimits(limits));
+ const limitsSummary = $derived(summarizeLimits(limits));
+ const parsedNewLimit = $derived(parseLimitInput(newLimitInput));
+ const parsedNewCooldown = $derived(parseCooldownInput(newCooldownInput));
+ /** Only send a cooldown PUT when the user moved it off the server default. */
+ const newCooldownChanged = $derived(
+ parsedNewCooldown !== null && parsedNewCooldown !== DEFAULT_COOLDOWN_MS,
+ );
+ const canSet = $derived(
+ newProviderId !== "" &&
+ parsedNewLimit !== null &&
+ parsedNewCooldown !== null &&
+ !limits.some((l) => l.providerId === newProviderId) &&
+ !adding,
+ );
+
+ // Keep the dropdown selection valid: default to the first option, and if the
+ // selected provider is removed from the options (e.g. its limit was deleted and
+ // it has no models), fall back to the first remaining option. Runs untracked so
+ // it doesn't loop on its own assignment.
+ $effect(() => {
+ const opts = providerOpts;
+ untrack(() => {
+ if (opts.length === 0) {
+ if (newProviderId !== "") newProviderId = "";
+ return;
+ }
+ if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? "";
+ });
+ });
+
+ async function refreshLimits(): Promise<void> {
+ if (limitsInFlight) return;
+ limitsInFlight = true;
+ const result = await loadLimits();
+ limitsInFlight = false;
+ hasLoadedLimits = true;
+ if (result.ok) {
+ limits = result.limits;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-refresh.
+ limitsError = null;
+ } else {
+ limitsError = result.error;
+ }
+ }
+
+ function startAdd(): void {
+ addOpen = true;
+ addError = null;
+ newLimitInput = "";
+ newCooldownInput = String(DEFAULT_COOLDOWN_MS);
+ // newProviderId is kept valid (defaults to the first option) by the effect above.
+ }
+
+ function cancelAdd(): void {
+ addOpen = false;
+ addError = null;
+ newLimitInput = "";
+ newCooldownInput = "";
+ }
+
+ // "Set" on the add row: save the limit, then the cooldown (only when the user
+ // moved it off the server default of 350ms — the backend defaults to 350 when a
+ // limit is set, so an unchanged value needs no extra PUT). On full success the
+ // add row closes + the limits/status reload (the new limit appears as a row).
+ async function handleAdd(): Promise<void> {
+ if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return;
+ adding = true;
+ addError = null;
+ const limitResult = await saveLimit(newProviderId, parsedNewLimit);
+ if (!limitResult.ok) {
+ adding = false;
+ addError = limitResult.error;
+ return;
+ }
+ if (newCooldownChanged) {
+ const cooldownResult = await saveCooldown(newProviderId, parsedNewCooldown);
+ adding = false;
+ if (!cooldownResult.ok) {
+ // The limit was saved (→ a row will appear after reload); the cooldown PUT
+ // failed. Surface the error but keep the add row open so it's visible. The
+ // user can edit the cooldown on the now-saved row.
+ addError = cooldownResult.error;
+ void refreshLimits();
+ void refreshStatus();
+ return;
+ }
+ } else {
+ adding = false;
+ }
+ addOpen = false;
+ newLimitInput = "";
+ newCooldownInput = "";
+ void refreshLimits();
+ void refreshStatus();
+ }
+
+ // Wrap the ports so a row's save/remove reloads the authoritative list + status
+ // on success (the row still gets the result to drive its own UI).
+ async function rowSave(providerId: string, limit: number) {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ async function rowRemove(providerId: string) {
+ const result = await deleteLimit(providerId);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ // Wrap the cooldown save so a successful PUT refreshes the live status (which
+ // re-carries the new `cooldownMs`). The row still gets the result to drive its
+ // own UI.
+ async function cooldownSave(providerId: string, cooldownMs: number) {
+ const result = await saveCooldown(providerId, cooldownMs);
+ if (result.ok) {
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ // ── Live status (polls while mounted — seeds cooldown inputs + drives the
+ // auto-reduce banners; the poll is silent, no status cards) ────────────────
+ let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
+ let statusError = $state<string | null>(null);
+ /** True after the first load settles (gates the empty state). */
+ let hasLoadedStatus = $state(false);
+ /** Re-entrancy guard for the 2s background poll (no UI — a visible loading
+ * indicator flickered every poll because the refresh is near-instant; it stays
+ * INVISIBLE, mirroring the heartbeat runs list). */
+ let statusInFlight = false;
+
+ // Per-provider status view (from the live status poll) so each saved limit row
+ // renders its in-flight count + status badge + seeds its cooldown input. Null
+ // when a provider has no status entry yet (the row falls back to Idle + the
+ // server-default cooldown of 350).
+ const statusByProvider = $derived.by(() => {
+ const map = new Map<string, ConcurrencyStatusView>();
+ for (const e of statusEntries) map.set(e.providerId, viewConcurrencyStatus(e));
+ return map;
+ });
+
+ // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ───────
+ //
+ // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status`
+ // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a
+ // banner per such provider. The banner is DISMISSIBLE: a dismissed provider
+ // stays hidden while it remains auto-reduced (persist-while-true), and is
+ // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future
+ // auto-reduce re-shows the banner. Restoring the limit (PUT) clears
+ // `autoReduced` server-side → the next poll drops the banner automatically.
+ //
+ // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to
+ // localStorage / a module-global): it resets on remount (sidebar view switch /
+ // reload). This is correct — `autoReduced` is a REAL persisted degraded state,
+ // so re-showing the banner on a fresh mount reminds the user. Persisting a
+ // dismissal across reloads would risk HIDING an ongoing degradation (a
+ // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the
+ // component-local `limitsError`/`statusError` pattern.
+ let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set());
+
+ const allNotices = $derived(autoReduceNotices(statusEntries));
+ const visibleNotices = $derived(
+ allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)),
+ );
+
+ // Reconcile the dismissed set against the live auto-reduced providers: keep a
+ // dismissed entry ONLY while its provider is still auto-reduced. A provider
+ // that has been restored (no longer in `allNotices`) is dropped from the
+ // dismissed set so a future auto-reduce re-shows its banner.
+ $effect(() => {
+ const autoReducedIds = new Set(allNotices.map((n) => n.providerId));
+ untrack(() => {
+ let changed = false;
+ const next = new Set<string>();
+ for (const id of dismissedAutoReduce) {
+ if (autoReducedIds.has(id)) next.add(id);
+ else changed = true;
+ }
+ if (changed) dismissedAutoReduce = next;
+ });
+ });
+
+ function dismissAutoReduce(providerId: string): void {
+ if (dismissedAutoReduce.has(providerId)) return;
+ dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]);
+ }
+
+ // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits
+ // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits +
+ // status on success; the next status poll shows `autoReduced===false` and the
+ // banner drops (the dismissed-set effect above un-dismisses it too). The banner
+ // component owns its own restoring-spinner + inline error; on FAILURE the
+ // outcome is bubbled back so the banner shows the error inline (instead of
+ // silently re-enabling the button / surfacing it only in the limits section).
+ async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ return { ok: true };
+ }
+ return { ok: false, error: result.error };
+ }
+
+ async function refreshStatus(): Promise<void> {
+ if (statusInFlight) return;
+ statusInFlight = true;
+ const result = await loadStatus();
+ statusInFlight = false;
+ hasLoadedStatus = true;
+ if (result.ok) {
+ statusEntries = result.providers;
+ // Clear the error only on success so it stays visible (stable, no flicker)
+ // during an in-flight retry rather than vanishing mid-poll.
+ statusError = null;
+ } else {
+ statusError = result.error;
+ }
+ }
+
+ const STATUS_POLL_MS = 2000;
+
+ // Load limits + status on mount, and poll the live status while the view is
+ // alive (so a saved limit's cooldown input re-seeds + auto-reduce banners stay
+ // fresh without a manual refresh). Runs once — no reactive deps read inside.
+ $effect(() => {
+ untrack(() => {
+ void refreshLimits();
+ void refreshStatus();
+ });
+ const h = setInterval(() => {
+ void refreshStatus();
+ }, STATUS_POLL_MS);
+ return () => clearInterval(h);
+ });
+</script>
+
+<div class="flex flex-col gap-4">
+ <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) -->
+ {#if visibleNotices.length > 0}
+ <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices">
+ {#each visibleNotices as notice (notice.providerId)}
+ <AutoReduceBanner {notice} onRestore={restoreLimit} onDismiss={dismissAutoReduce} />
+ {/each}
+ </section>
+ {/if}
+
+ <!-- Limits (config) — a single list of editable rows. -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => {
+ void refreshLimits();
+ void refreshStatus();
+ }}
+ aria-label="Refresh concurrency limits"
+ >
+ Refresh
+ </button>
+ </div>
+
+ <span class="text-xs opacity-70">{limitsSummary}</span>
+
+ {#if limitsError}
+ <p class="text-xs text-error">{limitsError}</p>
+ {:else if hasLoadedLimits && limitViews.length === 0 && !addOpen}
+ <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
+ {/if}
+
+ <ul class="flex flex-col gap-2">
+ {#each limitViews as limit (limit.providerId)}
+ <li>
+ <ConcurrencyLimitRow
+ {limit}
+ status={statusByProvider.get(limit.providerId) ?? null}
+ save={rowSave}
+ saveCooldown={cooldownSave}
+ remove={rowRemove}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ <!-- Add row: an "Add" button reveals a new item (dropdown + limit + cooldown
+ + Set + ✕). Set saves the limit (+ cooldown when moved off the default);
+ ✕ cancels the draft. -->
+ {#if addOpen}
+ <div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <!-- All on one line — nowrap so the buttons never wrap (tight gap + narrow
+ inputs keep the provider dropdown visible; it shrinks via flex-1 + min-w-0). -->
+ <div class="flex flex-nowrap items-center gap-1">
+ <select
+ class="select select-bordered select-xs min-w-0 flex-1 font-mono"
+ aria-label="Provider"
+ bind:value={newProviderId}
+ disabled={adding || providerOpts.length === 0}
+ >
+ {#if providerOpts.length === 0}
+ <option value="" disabled>No providers available</option>
+ {:else}
+ {#each providerOpts as provider (provider)}
+ <option value={provider}>{provider}</option>
+ {/each}
+ {/if}
+ </select>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-12 min-w-0 font-mono"
+ placeholder="4"
+ aria-label="New concurrency limit"
+ bind:value={newLimitInput}
+ disabled={adding}
+ />
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-14 min-w-0 font-mono"
+ aria-label="New release cooldown (ms)"
+ bind:value={newCooldownInput}
+ disabled={adding}
+ />
+ <span class="shrink-0 text-[10px] opacity-50">ms</span>
+ <button
+ type="button"
+ class="btn btn-primary btn-xs shrink-0"
+ disabled={!canSet}
+ onclick={handleAdd}
+ >
+ {#if adding}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs shrink-0 text-error"
+ aria-label="Cancel add"
+ disabled={adding}
+ onclick={cancelAdd}
+ >
+ ✕
+ </button>
+ </div>
+ {#if addError}
+ <p class="font-mono text-xs text-error">{addError}</p>
+ {/if}
+ </div>
+ {:else}
+ <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={startAdd}>
+ + Add
+ </button>
+ {/if}
+ </section>
+</div>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
new file mode 100644
index 0000000..a8163c2
--- /dev/null
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -0,0 +1,559 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../logic/types";
+import ConcurrencyView from "./ConcurrencyView.svelte";
+
+// Available models → provider ids are "umans", "anthropic", "openai-compat".
+const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const;
+
+// A status entry factory (defaults to a healthy limited provider). The new
+// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present.
+function statusEntry(over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry {
+ return {
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ cooldownMs: 350,
+ autoReduced: false,
+ ...over,
+ };
+}
+
+// Fakes for the injected ports. Each resolves immediately so the mount effect's
+// initial load settles in a microtask (assertions await via findBy*). The status
+// list is mutable so a test can flip `autoReduced` between polls to simulate a
+// restore clearing the banner.
+function makeFakes(opts?: {
+ limits?: readonly { providerId: string; limit: number }[];
+ status?: ConcurrencyStatusEntry[];
+ /**
+ * When set, `saveLimit` rejects with this error (returns `ok: false`) — used
+ * to test the auto-reduce banner's inline restore-error feedback.
+ */
+ saveLimitError?: string;
+ /**
+ * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a
+ * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced`
+ * on the next status poll). Receives the providerId + limit + the fakes bag so
+ * it can mutate the status list. (A plain method reassignment would NOT reach
+ * the already-rendered component — the prop captured the original closure.)
+ */
+ onSaveLimit?: (
+ providerId: string,
+ limit: number,
+ self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void },
+ ) => void;
+}) {
+ let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }];
+ let status = opts?.status ?? [statusEntry()];
+ const onSaveLimit = opts?.onSaveLimit;
+ const saveLimitError = opts?.saveLimitError;
+
+ const calls: MakeFakesCalls = {
+ loadLimits: 0,
+ loadStatus: 0,
+ saves: [] as { providerId: string; limit: number }[],
+ deletes: [] as string[],
+ cooldownSaves: [] as { providerId: string; cooldownMs: number }[],
+ };
+
+ function setStatus(next: ConcurrencyStatusEntry[]): void {
+ status = next;
+ }
+
+ return {
+ calls,
+ // Allow a test to mutate the status list between polls (e.g. clear
+ // autoReduced after a restore to simulate the next poll).
+ setStatus,
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => {
+ calls.loadLimits++;
+ return { ok: true, limits };
+ },
+ saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
+ calls.saves.push({ providerId, limit });
+ if (saveLimitError !== undefined) {
+ return { ok: false, error: saveLimitError };
+ }
+ // Reflect the new limit into the list the next load returns.
+ limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }];
+ if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus });
+ return { ok: true, providerId, limit };
+ },
+ deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => {
+ calls.deletes.push(providerId);
+ limits = limits.filter((l) => l.providerId !== providerId);
+ return { ok: true, providerId };
+ },
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => {
+ calls.loadStatus++;
+ return { ok: true, providers: status };
+ },
+ saveCooldown: async (
+ providerId: string,
+ cooldownMs: number,
+ ): Promise<ConcurrencyCooldownResult> => {
+ calls.cooldownSaves.push({ providerId, cooldownMs });
+ // Reflect the new cooldown into the status list the next load returns.
+ status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s));
+ return { ok: true, providerId, cooldownMs };
+ },
+ };
+}
+
+type MakeFakesCalls = {
+ loadLimits: number;
+ loadStatus: number;
+ saves: { providerId: string; limit: number }[];
+ deletes: string[];
+ cooldownSaves: { providerId: string; cooldownMs: number }[];
+};
+
+function props(fakes: ReturnType<typeof makeFakes>) {
+ return {
+ models: MODELS as unknown as readonly string[],
+ loadLimits: fakes.loadLimits,
+ saveLimit: fakes.saveLimit,
+ deleteLimit: fakes.deleteLimit,
+ loadStatus: fakes.loadStatus,
+ saveCooldown: fakes.saveCooldown,
+ };
+}
+
+describe("ConcurrencyView", () => {
+ it("loads + renders the configured limits list on mount", async () => {
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The limits summary + the row's remove control (unique to the limits list).
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible();
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1);
+ expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1);
+ });
+
+ it("renders the per-provider cooldown input seeded from the live status", async () => {
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The saved row's cooldown input (in the same row as the limit) is seeded 350.
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+ });
+
+ it("renders a status line (in-flight count left + badge right) below the edit line", async () => {
+ // Default status: limit 4, inFlight 2, queued 1, not paused → Active, "2/4".
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("2/4 in flight")).toBeVisible();
+ expect(await screen.findByText("Active")).toBeVisible();
+ });
+
+ it("shows the At-capacity badge when in-flight is at the cap with a queue", async () => {
+ const fakes = makeFakes({
+ status: [statusEntry({ inFlight: 4, limit: 4, queued: 3 })],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("4/4 in flight")).toBeVisible();
+ expect(await screen.findByText("At capacity")).toBeVisible();
+ });
+
+ it("shows the Idle badge when no slots are in flight", async () => {
+ const fakes = makeFakes({
+ status: [statusEntry({ inFlight: 0, limit: 4, queued: 0 })],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText("0/4 in flight")).toBeVisible();
+ expect(await screen.findByText("Idle")).toBeVisible();
+ });
+
+ it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => {
+ // The 2s status poll + post-mutation reloads are SILENT: they never toggle a
+ // visible loading state, so the Refresh button is plain-text (no spinner).
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/1 limit configured/);
+
+ const limitsRefresh = screen.getByLabelText("Refresh concurrency limits");
+ expect(limitsRefresh).toHaveTextContent("Refresh");
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+ expect(limitsRefresh).not.toBeDisabled();
+
+ // A manual refresh stays silent too (no spinner appears).
+ await fakes.loadStatus();
+ expect(limitsRefresh.querySelector(".loading-spinner")).toBeNull();
+ });
+
+ it("shows an empty state + Add button when no limits are configured", async () => {
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ expect(await screen.findByText(/No limits configured/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ // No provider dropdown until Add is clicked.
+ expect(screen.queryByLabelText("Provider")).toBeNull();
+ });
+
+ it("reveals a new item row (dropdown + limit + cooldown + Set + ✕) when Add is clicked", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ // The new-item row appears with a provider dropdown (auto-selected first),
+ // a limit input, a cooldown input (seeded with the default 350), Set + ✕.
+ const providerSelect = screen.getByLabelText("Provider");
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ expect(screen.getByPlaceholderText("4")).toBeVisible();
+ const cooldownInput = screen.getByLabelText("New release cooldown (ms)");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+ expect(screen.getByRole("button", { name: "Set" })).toBeVisible();
+ expect(screen.getByRole("button", { name: "Cancel add" })).toBeVisible();
+ });
+
+ it("adds a provider limit via the new-item row Set (calls saveLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ // Choose "anthropic" from the dropdown (the list is auto-selected first).
+ await user.selectOptions(providerSelect, "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ // The cooldown was left at the default (350) → no extra cooldown PUT fired.
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ // After save the component reloads the limits list (now showing the row).
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument();
+ expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible();
+ // The add row closed back to the Add button.
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ });
+
+ it("sends a cooldown PUT when the new item's cooldown is moved off the default", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ await user.selectOptions(screen.getByLabelText("Provider"), "anthropic");
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ const cooldownInput = screen.getByLabelText("New release cooldown (ms)");
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]);
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "anthropic", cooldownMs: 500 }]);
+ });
+
+ it("disables Set when the limit is empty/invalid (provider is auto-selected)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ // A provider is auto-selected from the dropdown.
+ expect((providerSelect as HTMLSelectElement).value).not.toBe("");
+ const setBtn = screen.getByRole("button", { name: "Set" });
+ expect(setBtn).toBeDisabled(); // no limit entered yet
+
+ // An invalid (non-numeric) limit keeps Set disabled.
+ await user.type(screen.getByPlaceholderText("4"), "abc");
+ expect(setBtn).toBeDisabled();
+
+ // A valid positive-integer limit enables Set.
+ const limitInput = screen.getByPlaceholderText("4");
+ await user.clear(limitInput);
+ await user.type(limitInput, "5");
+ expect(setBtn).toBeEnabled();
+ });
+
+ it("shows no-providers + disables the dropdown when there are no models", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, {
+ props: { ...props(fakes), models: [] as unknown as readonly string[] },
+ });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+
+ const providerSelect = screen.getByLabelText("Provider");
+ expect(providerSelect).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
+
+ it("cancels the new-item row (✕) without saving", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ limits: [], status: [] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByText(/No limits configured/);
+ await user.click(screen.getByRole("button", { name: "+ Add" }));
+ await user.type(screen.getByPlaceholderText("4"), "8");
+ await user.click(screen.getByRole("button", { name: "Cancel add" }));
+
+ // The row collapses back to the Add button; nothing was saved.
+ expect(screen.queryByLabelText("Provider")).toBeNull();
+ expect(screen.getByRole("button", { name: "+ Add" })).toBeVisible();
+ expect(fakes.calls.saves).toHaveLength(0);
+ });
+
+ it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the limits to load (unique summary) before interacting.
+ await screen.findByText(/1 limit configured/);
+ await user.click(screen.getByLabelText("Remove concurrency limit for umans"));
+
+ expect(fakes.calls.deletes).toEqual(["umans"]);
+ expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2);
+ });
+
+ it("surfaces a load error from the limits endpoint", async () => {
+ const failing = {
+ models: MODELS as unknown as readonly string[],
+ loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({
+ ok: false,
+ error: "Concurrency service not available",
+ }),
+ saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }),
+ deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }),
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }),
+ saveCooldown: async (): Promise<ConcurrencyCooldownResult> => ({ ok: false, error: "noop" }),
+ };
+ render(ConcurrencyView, { props: failing });
+ expect(await screen.findByText("Concurrency service not available")).toBeVisible();
+ });
+
+ // ── Concurrency-fixes: auto-reduce banner + cooldown editing ────────────────
+
+ it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => {
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // The banner shows the backend notice verbatim + a "Restore to 4" action.
+ expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible();
+ expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ // The "Was 4, now 3." provenance line is shown.
+ expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible();
+ });
+
+ it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => {
+ const user = userEvent.setup();
+ // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced`
+ // server-side; the next status poll returns limit 4 + autoReduced===false →
+ // the banner drops.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ // Simulate the backend clearing `autoReduced` on the manual PUT: the next
+ // status load returns the restored limit with autoReduced===false.
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The restore PUT the limit back to the original (autoReducedFrom = 4).
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ // The banner is gone (no Restore button, no notice text); the limits list
+ // now reflects the restored limit (the row's limit input re-seeds to 4).
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ await screen.findByRole("button", { name: /Restore to 4/ });
+ // Dismiss the banner (hide locally — the provider is still auto-reduced).
+ await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans"));
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull();
+ });
+
+ it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ saveLimitError: "Concurrency service not available",
+ });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+
+ // The error surfaces INLINE in the banner (near the restore action), not
+ // only in the far-away limits section. The banner is still present (restore
+ // did not succeed) and the button re-enabled for a retry.
+ expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent(
+ "Concurrency service not available",
+ );
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible();
+ expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled();
+ // The restore PUT was attempted.
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]);
+ });
+
+ it("clears the inline restore error on a retry that succeeds", async () => {
+ const user = userEvent.setup();
+ // First restore fails; the second succeeds (clears autoReduced). Reassigning
+ // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the
+ // rendered component, so swap it BEFORE render here.
+ const fakes = makeFakes({
+ status: [
+ statusEntry({
+ limit: 3,
+ autoReduced: true,
+ autoReducedFrom: 4,
+ notice: "Concurrency limit auto-reduced to 3 after a 429.",
+ }),
+ ],
+ onSaveLimit: (_providerId, limit, self) => {
+ self.setStatus([statusEntry({ limit, autoReduced: false })]);
+ },
+ });
+ let attempts = 0;
+ const succeeding = fakes.saveLimit;
+ fakes.saveLimit = async (providerId, limit) => {
+ attempts++;
+ if (attempts === 1) return { ok: false, error: "Concurrency service not available" };
+ return succeeding(providerId, limit);
+ };
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ });
+ await user.click(restoreBtn);
+ // First attempt: inline error appears.
+ expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument();
+
+ // Retry: the error clears, the banner drops (restore succeeded).
+ await user.click(screen.getByRole("button", { name: /Restore to 4/ }));
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+ expect(screen.queryByTestId("restore-error-umans")).toBeNull();
+ expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull();
+ });
+
+ it("edits the per-provider cooldown in the limit row (PUT /concurrency/cooldown + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ // Wait for the saved row + its cooldown input (seeded with 350).
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ expect((cooldownInput as HTMLInputElement).value).toBe("350");
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "500");
+ await user.click(screen.getByRole("button", { name: "Set concurrency for umans" }));
+
+ // The cooldown PUT fired with the new value.
+ expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]);
+ // The limit was NOT re-saved (unchanged) — only the cooldown PUT fired.
+ expect(fakes.calls.saves).toHaveLength(0);
+ });
+
+ it("edits the per-provider limit in the row (PUT /concurrency/limits + reloads)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes();
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const limitInput = await screen.findByLabelText("Concurrency limit for umans");
+ expect((limitInput as HTMLInputElement).value).toBe("4");
+
+ await user.clear(limitInput);
+ await user.type(limitInput, "8");
+ await user.click(screen.getByRole("button", { name: "Set concurrency for umans" }));
+
+ expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 8 }]);
+ // Cooldown unchanged → no cooldown PUT.
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
+
+ it("rejects a negative cooldown input (Set disabled — non-negative integer only)", async () => {
+ const user = userEvent.setup();
+ const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] });
+ render(ConcurrencyView, { props: props(fakes) });
+
+ const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans");
+ // 0 is valid (no cooldown); a negative is not.
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "0");
+ expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeEnabled();
+
+ await user.clear(cooldownInput);
+ await user.type(cooldownInput, "-5");
+ expect(screen.getByRole("button", { name: "Set concurrency for umans" })).toBeDisabled();
+ expect(fakes.calls.cooldownSaves).toHaveLength(0);
+ });
+});
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 ba3f69a..25e2af7 100644
--- a/src/features/conversation-cache/index.ts
+++ b/src/features/conversation-cache/index.ts
@@ -2,7 +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",
+} 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
new file mode 100644
index 0000000..36ea1b2
--- /dev/null
+++ b/src/features/cwd-lsp/index.ts
@@ -0,0 +1,14 @@
+export type {
+ 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",
+} as const;
diff --git a/src/features/cwd-lsp/logic/view-model.test.ts b/src/features/cwd-lsp/logic/view-model.test.ts
new file mode 100644
index 0000000..7bc0aad
--- /dev/null
+++ b/src/features/cwd-lsp/logic/view-model.test.ts
@@ -0,0 +1,101 @@
+import type { LspServerInfo } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ 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,
+});
+
+describe("cwd helpers", () => {
+ 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("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("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);
+ });
+
+ 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");
+ });
+
+ 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("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
new file mode 100644
index 0000000..7642418
--- /dev/null
+++ b/src/features/cwd-lsp/logic/view-model.ts
@@ -0,0 +1,130 @@
+import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the cwd-lsp feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The cwd-lsp feature exposes a conversation's per-tab working directory (cwd)
+ * and the live status of the language servers configured for that cwd. This
+ * module holds the pure logic: cwd normalization/validation, the mapping of a
+ * backend `LspServerState` to a display badge, and a one-line server summary.
+ * The effects (the HTTP get/set cwd + get LSP status) are INJECTED via the ports
+ * below; the composition root implements them.
+ */
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+/** 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 };
+
+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 };
+
+export type LoadLspStatus = () => Promise<LspStatusResult | null>;
+
+// ── cwd helpers ───────────────────────────────────────────────────────────────
+
+/** Trim surrounding whitespace; the backend rejects an empty cwd. */
+export function normalizeCwd(raw: string): string {
+ return raw.trim();
+}
+
+/** Whether a typed cwd is submittable (non-empty after trim). */
+export function isSubmittableCwd(raw: string): boolean {
+ return normalizeCwd(raw).length > 0;
+}
+
+/**
+ * Whether saving `typed` would change the persisted `current` cwd. A no-op save
+ * (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 ?? "");
+}
+
+// ── LSP server status → display view ──────────────────────────────────────────
+
+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;
+}
+
+/** 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,
+ };
+}
+
+export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] {
+ 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(", ");
+}
diff --git a/src/features/cwd-lsp/ui/CwdField.svelte b/src/features/cwd-lsp/ui/CwdField.svelte
new file mode 100644
index 0000000..c83f5ce
--- /dev/null
+++ b/src/features/cwd-lsp/ui/CwdField.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ 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();
+
+ // 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;
+ });
+ });
+
+ 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;
+ }
+ }
+
+ 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}
+</div>
diff --git a/src/features/cwd-lsp/ui/LspStatusView.svelte b/src/features/cwd-lsp/ui/LspStatusView.svelte
new file mode 100644
index 0000000..c8c9ea8
--- /dev/null
+++ b/src/features/cwd-lsp/ui/LspStatusView.svelte
@@ -0,0 +1,129 @@
+<script lang="ts">
+ 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();
+
+ 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("");
+
+ 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();
+ });
+ });
+</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>
+
+ {#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/heartbeat/index.ts b/src/features/heartbeat/index.ts
new file mode 100644
index 0000000..cc438f1
--- /dev/null
+++ b/src/features/heartbeat/index.ts
@@ -0,0 +1,50 @@
+export type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+} from "./logic/types";
+export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model";
+export {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
+} from "./logic/view-model";
+export { default as HeartbeatView } from "./ui/HeartbeatView.svelte";
+export { default as PromptEditor } from "./ui/PromptEditor.svelte";
+export { default as RunModal } from "./ui/RunModal.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "heartbeat",
+ description: "Workspace autonomous-agent heartbeat: config, run history, live run chat",
+} as const;
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
new file mode 100644
index 0000000..3d3d525
--- /dev/null
+++ b/src/features/heartbeat/logic/types.ts
@@ -0,0 +1,103 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the heartbeat feature — zero DOM, zero effects, zero Svelte.
+ *
+ * Heartbeat is a workspace-scoped autonomous agent loop: the backend periodically
+ * runs a turn in a dedicated conversation using a configured system prompt, task
+ * prompt, model, reasoning effort, and interval. The FE exposes the config
+ * (`GET`/`PUT /workspaces/:id/heartbeat`), the run history
+ * (`GET /workspaces/:id/heartbeat/runs`), and a per-run stop
+ * (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ *
+ * The backend's heartbeat API is a plain REST surface — it is NOT part of the
+ * shared `@dispatch/transport-contract` / `@dispatch/wire` packages (verified:
+ * no `heartbeat` symbol in either `dist/`). So, following the consumer-defines-
+ * port pattern (mirrors `features/mcp` / `features/computer` result types), the
+ * FE owns these shapes here and adapts the untyped JSON at the network seam in
+ * the composition root. If the backend later promotes these to a shared contract
+ * package, swap the local types for the imports (see `backend-handoff.md`).
+ */
+
+/** The canonical run lifecycle status (backend-owned enum, verbatim). */
+export type HeartbeatRunStatus = "running" | "completed" | "stopped";
+
+/** The workspace's heartbeat configuration (`GET /workspaces/:id/heartbeat`). */
+export interface HeartbeatConfig {
+ /** Whether the autonomous loop is enabled (running on the interval). */
+ readonly enabled: boolean;
+ readonly systemPrompt: string;
+ readonly taskPrompt: string;
+ /** Minutes between runs. */
+ readonly intervalMinutes: number;
+ /** The model name (`<credential>/<model>`) the heartbeat runs with. */
+ readonly model: string;
+ /**
+ * The heartbeat's reasoning effort, or null when never set (the server
+ * default `"high"` then applies) — mirrors the per-conversation knob's
+ * resolution chain.
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * A partial config patch for `PUT /workspaces/:id/heartbeat`. Every field is
+ * optional — the backend merges the patch onto the stored config.
+ */
+export interface HeartbeatConfigPatch {
+ readonly enabled?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
+}
+
+/** One heartbeat run (`GET /workspaces/:id/heartbeat/runs`). */
+export interface HeartbeatRun {
+ readonly id: string;
+ /** The conversation this run wrote to (watch it live for the chat). */
+ readonly conversationId: string;
+ /** ISO timestamp of when the run was triggered. */
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
+}
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+/** Outcome of `GET /workspaces/:id/heartbeat` (or the PUT response). */
+export type HeartbeatConfigResult =
+ | { readonly ok: true; readonly config: HeartbeatConfig }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /workspaces/:id/heartbeat/runs`. */
+export type HeartbeatRunsResult =
+ | { readonly ok: true; readonly runs: readonly HeartbeatRun[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
+export type HeartbeatStopResult =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>;
+export type SaveHeartbeatConfig = (
+ patch: HeartbeatConfigPatch,
+) => Promise<HeartbeatConfigResult | null>;
+export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>;
+export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>;
+
+/**
+ * Outcome of `GET /workspaces/:id/heartbeat/next-run` — the server-authoritative
+ * timestamp of the next scheduled heartbeat run (ISO 8601 string), or `null`
+ * when the heartbeat is disabled or no run is scheduled. The FE computes a live
+ * countdown from this + a 1s clock (see `formatCountdown`). When the endpoint is
+ * unavailable (404 — backend hasn't shipped it yet), the FE falls back to an
+ * approximation from the runs + config (see `approximateNextRunEpoch`).
+ */
+export type HeartbeatNextRunResult =
+ | { readonly ok: true; readonly nextRunAt: string | null }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatNextRun = () => Promise<HeartbeatNextRunResult | null>;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
new file mode 100644
index 0000000..aca0aa6
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -0,0 +1,470 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import type { HeartbeatConfig, HeartbeatRun } from "./types";
+import {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
+} from "./view-model";
+
+const NOW = Date.UTC(2026, 5, 25, 14, 30, 5); // 2026-06-25T14:30:05Z
+const ISO_AT = "2026-06-25T14:30:05Z"; // exactly NOW
+const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
+ id: "run-1",
+ conversationId: "conv-1",
+ triggeredAt: ISO_AT,
+ status: "completed",
+ ...over,
+});
+
+const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
+ enabled: false,
+ systemPrompt: "be helpful",
+ taskPrompt: "check status",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ ...over,
+});
+
+describe("badgeForStatus", () => {
+ it("running → warning + busy (spinner)", () => {
+ expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true });
+ });
+ it("completed → success, not busy", () => {
+ expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false });
+ });
+ it("stopped → neutral, not busy", () => {
+ expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false });
+ });
+});
+
+describe("statusLabelFor", () => {
+ it("maps each status to a display label", () => {
+ expect(statusLabelFor("running")).toBe("Running");
+ expect(statusLabelFor("completed")).toBe("Completed");
+ expect(statusLabelFor("stopped")).toBe("Stopped");
+ });
+});
+
+describe("formatRunTime", () => {
+ it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => {
+ // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05.
+ // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable.
+ expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3);
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(formatRunTime("not-a-date")).toBe("—");
+ expect(formatRunTime("")).toBe("—");
+ });
+});
+
+describe("relativeLabel", () => {
+ it("just now when within a minute", () => {
+ expect(relativeLabel(ISO_AT, NOW)).toBe("just now");
+ expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now");
+ });
+ it("Nm ago under an hour", () => {
+ expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago");
+ expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago");
+ });
+ it("Nh ago under a day", () => {
+ expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago");
+ });
+ it("absolute date+time past a day", () => {
+ const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000);
+ expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/);
+ });
+ it("future timestamp → just now (clock skew tolerance)", () => {
+ expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now");
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(relativeLabel("nope", NOW)).toBe("—");
+ });
+});
+
+describe("viewRun / viewRuns", () => {
+ it("running run: warning badge + busy + labels", () => {
+ const v = viewRun(run({ status: "running" }), NOW);
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ expect(v.statusLabel).toBe("Running");
+ expect(v.id).toBe("run-1");
+ expect(v.conversationId).toBe("conv-1");
+ expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(v.relativeLabel).toBe("just now");
+ });
+ it("completed run: success badge, not busy", () => {
+ expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success");
+ });
+ it("stopped run: neutral badge, not busy", () => {
+ expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral");
+ });
+ it("viewRuns preserves order", () => {
+ const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("config form", () => {
+ it("emptyForm has defaults (disabled, default interval split, default effort)", () => {
+ const f = emptyForm();
+ expect(f.enabled).toBe(false);
+ // 30 min → 0h 30m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(30);
+ expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT
+ expect(f.systemPrompt).toBe("");
+ expect(f.model).toBe("");
+ });
+
+ it("formFromConfig resolves null reasoningEffort to the default", () => {
+ const f = formFromConfig(config({ reasoningEffort: null }));
+ expect(f.reasoningEffort).toBe("high");
+ });
+
+ it("formFromConfig passes through a set reasoningEffort", () => {
+ const f = formFromConfig(config({ reasoningEffort: "max" }));
+ expect(f.reasoningEffort).toBe("max");
+ });
+
+ it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => {
+ expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 30,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 0,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({
+ intervalHours: 0,
+ intervalMinutes: 59,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({
+ intervalHours: 24,
+ intervalMinutes: 0,
+ });
+ });
+
+ it("formFromConfig coerces malformed fields safely", () => {
+ const f = formFromConfig(
+ config({
+ enabled: "yes" as unknown as boolean,
+ intervalMinutes: -5,
+ model: 42 as unknown as string,
+ systemPrompt: undefined as unknown as string,
+ }),
+ );
+ expect(f.enabled).toBe(false); // non-true → false
+ // -5 clamps to 1 → 0h 1m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(1);
+ expect(f.model).toBe(""); // non-string → ""
+ expect(f.systemPrompt).toBe(""); // undefined → ""
+ });
+
+ it("normalizeInterval clamps to 1–1440 and rounds", () => {
+ expect(normalizeInterval(0)).toBe(1);
+ expect(normalizeInterval(-10)).toBe(1);
+ expect(normalizeInterval(1.4)).toBe(1);
+ expect(normalizeInterval(15.6)).toBe(16);
+ expect(normalizeInterval(2000)).toBe(1440);
+ expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number
+ expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES);
+ });
+
+ it("splitInterval / joinInterval round-trip (and clamp)", () => {
+ expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 });
+ expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1
+ expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 });
+ expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped
+ // join recomputes + clamps
+ expect(joinInterval(1, 30)).toBe(90);
+ expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1
+ expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440
+ expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0
+ expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h
+ });
+
+ it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => {
+ const f = formFromConfig(config({ intervalMinutes: 2000 }));
+ // 2000 clamps to 1440 → 24h 0m in the form
+ expect(f.intervalHours).toBe(24);
+ expect(f.intervalMinutes).toBe(0);
+ const patch = patchFromForm(f);
+ expect(patch.intervalMinutes).toBe(1440);
+ expect(patch.enabled).toBe(false);
+ expect(patch.model).toBe("openai/gpt-4o");
+ expect(patch.reasoningEffort).toBe("high");
+ expect(patch.systemPrompt).toBe("be helpful");
+ expect(patch.taskPrompt).toBe("check status");
+ });
+
+ it("patchFromForm recombines an arbitrary hours/minutes edit", () => {
+ const f = formFromConfig(config({ intervalMinutes: 15 }));
+ f.intervalHours = 2;
+ f.intervalMinutes = 45;
+ expect(patchFromForm(f).intervalMinutes).toBe(165);
+ });
+
+ it("formDiffers is false for a form seeded from the config (no edits)", () => {
+ const c = config({ reasoningEffort: "medium" });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
+
+ it("formDiffers is true after an edit", () => {
+ const c = config();
+ const f = formFromConfig(c);
+ f.systemPrompt = "changed";
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is true after an interval edit (hours or minutes)", () => {
+ const c = config({ intervalMinutes: 90 });
+ const f = formFromConfig(c);
+ f.intervalMinutes = 45; // 1h45m vs 1h30m
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers treats null config effort as the default (matches the resolved form)", () => {
+ const c = config({ reasoningEffort: null });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
+ });
+});
+
+describe("system-prompt inheritance (override ⇄ global default)", () => {
+ const DEFAULT = "You are a helpful assistant.";
+
+ it("effectiveSystemPrompt: override wins when non-empty, else the default", () => {
+ expect(effectiveSystemPrompt("custom", DEFAULT)).toBe("custom");
+ expect(effectiveSystemPrompt("", DEFAULT)).toBe(DEFAULT);
+ });
+
+ it("isInheritingSystemPrompt: true iff the override is empty", () => {
+ expect(isInheritingSystemPrompt("")).toBe(true);
+ expect(isInheritingSystemPrompt("custom")).toBe(false);
+ });
+
+ it('persistedSystemPrompt: empty or matching-the-default → inherit ("")', () => {
+ // matching the default → inherit (never duplicate the default into the config)
+ expect(persistedSystemPrompt(DEFAULT, DEFAULT)).toBe("");
+ // empty edit → inherit
+ expect(persistedSystemPrompt("", DEFAULT)).toBe("");
+ });
+
+ it("persistedSystemPrompt: a distinct edit → the override verbatim", () => {
+ expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom");
+ expect(persistedSystemPrompt(`${DEFAULT}\nmore`, DEFAULT)).toBe(`${DEFAULT}\nmore`);
+ });
+
+ it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => {
+ // A heartbeat inheriting (override "") displays the default; with no edit,
+ // persisting yields inherit ("") — so the global default stays the source.
+ const override = "";
+ const displayed = effectiveSystemPrompt(override, DEFAULT);
+ expect(displayed).toBe(DEFAULT);
+ expect(persistedSystemPrompt(displayed, DEFAULT)).toBe("");
+ });
+
+ it("round-trip: override → reset to default → persist inherit (clears override)", () => {
+ // User had an override, clicks Reset (textarea ← default): persisting clears
+ // the override ("" → inherit) because the text now matches the default.
+ const afterReset = DEFAULT;
+ expect(persistedSystemPrompt(afterReset, DEFAULT)).toBe("");
+ });
+});
+
+describe("effortOptions re-export", () => {
+ it("exposes the canonical ladder with the default marked", () => {
+ const opts = effortOptions();
+ const values = opts.map((o) => o.value) as readonly string[];
+ expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ const def = opts.find((o) => o.value === "high");
+ expect(def?.label).toBe("high (default)");
+ });
+});
+
+describe("reasoningEffort type narrowing (sanity)", () => {
+ // Ensures the imported ladder stays the wire's canonical set — if the wire
+ // ladder changes, this test flags the drift alongside the chat feature.
+ it("the five canonical levels", () => {
+ const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
+ expect(levels).toHaveLength(5);
+ });
+});
+
+describe("normalizeHeartbeatConfig", () => {
+ it("passes through a well-formed config", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ expect(c).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ });
+ it("coerces a malformed body safely (never throws, never undefined)", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: "yes",
+ intervalMinutes: -3,
+ reasoningEffort: "bogus",
+ });
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(1);
+ expect(c.reasoningEffort).toBeNull();
+ expect(c.systemPrompt).toBe("");
+ expect(c.taskPrompt).toBe("");
+ expect(c.model).toBe("");
+ });
+ it("accepts a null reasoningEffort", () => {
+ expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull();
+ });
+ it("handles null / non-object input", () => {
+ const c = normalizeHeartbeatConfig(null);
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
+ expect(c.model).toBe("");
+ });
+ it("clamps a huge interval", () => {
+ expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
+ });
+});
+
+describe("normalizeHeartbeatRuns", () => {
+ it("maps a well-formed runs list", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" },
+ {
+ id: "r2",
+ conversationId: "c2",
+ triggeredAt: "2026-06-25T09:00:00Z",
+ status: "completed",
+ },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]).toMatchObject({ id: "r1", status: "running" });
+ expect(runs[1]).toMatchObject({ id: "r2", status: "completed" });
+ });
+ it("returns [] for malformed body", () => {
+ expect(normalizeHeartbeatRuns(null)).toEqual([]);
+ expect(normalizeHeartbeatRuns({})).toEqual([]);
+ expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]);
+ });
+ it("drops runs missing id/conversationId and defaults unknown status", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" },
+ { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" },
+ { id: "r3", conversationId: "", triggeredAt: "x", status: "running" },
+ { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]?.status).toBe("completed"); // "garbage" → default
+ expect(runs[0]?.id).toBe("r1");
+ expect(runs[1]?.id).toBe("r4");
+ });
+});
+
+describe("next-run countdown", () => {
+ const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour
+
+ describe("nextRunEpoch", () => {
+ it("parses an ISO timestamp to epoch-ms", () => {
+ expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT));
+ });
+ it("returns null for unparseable / empty / non-string", () => {
+ expect(nextRunEpoch("not-a-date")).toBeNull();
+ expect(nextRunEpoch("")).toBeNull();
+ expect(nextRunEpoch(null)).toBeNull();
+ expect(nextRunEpoch(undefined)).toBeNull();
+ });
+ });
+
+ describe("formatCountdown", () => {
+ it("null → —", () => {
+ expect(formatCountdown(null)).toBe("—");
+ });
+ it("≤ 0 → due", () => {
+ expect(formatCountdown(0)).toBe("due");
+ expect(formatCountdown(-5000)).toBe("due");
+ });
+ it("seconds only (< 1m)", () => {
+ expect(formatCountdown(32_000)).toBe("32s");
+ expect(formatCountdown(1_000)).toBe("1s");
+ });
+ it("minutes + seconds (1m–1h)", () => {
+ expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s");
+ expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s");
+ });
+ it("hours + minutes (≥ 1h)", () => {
+ expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m");
+ expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m");
+ });
+ });
+
+ describe("approximateNextRunEpoch", () => {
+ const runs = (times: string[]): HeartbeatRun[] =>
+ times.map((t, i) => ({
+ id: `r${i}`,
+ conversationId: "c",
+ triggeredAt: t,
+ status: "completed",
+ }));
+
+ it("disabled → null", () => {
+ expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull();
+ });
+ it("no runs → null (no fabricated countdown)", () => {
+ expect(approximateNextRunEpoch([], 15, true)).toBeNull();
+ });
+ it("latest run + interval (minutes)", () => {
+ // latest is the max triggeredAt (runs need not be ordered)
+ const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]);
+ // 13:50 + 15 min = 14:05
+ expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("ignores unparseable triggeredAt values", () => {
+ const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]);
+ expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("all-unparseable → null", () => {
+ expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull();
+ });
+ });
+});
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
new file mode 100644
index 0000000..e91febd
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -0,0 +1,410 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import {
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+} from "../../chat/reasoning-effort";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+} from "./types";
+
+/**
+ * Pure view-models for the heartbeat feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `HeartbeatConfig`/`HeartbeatRun` to display shapes
+ * (badges, labels, formatted times) and holds the config-form helpers.
+ *
+ * The reasoning-effort ladder + resolution are SERVER-owned and shared with the
+ * per-conversation knob, so they are REUSED from `features/chat/reasoning-effort`
+ * (a sanctioned cross-feature import through its public exports) rather than
+ * redefined — no drift.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A run shaped for display in the scrolling runs list. */
+export interface HeartbeatRunView {
+ readonly id: string;
+ readonly conversationId: string;
+ readonly status: HeartbeatRunStatus;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the run is in flight (show a spinner). */
+ readonly busy: boolean;
+ /** A short absolute clock label, e.g. "14:30:05". */
+ readonly timeLabel: string;
+ /** A relative label, e.g. "5m ago" / "just now". */
+ readonly relativeLabel: string;
+}
+
+const RUNNING_LABEL = "Running";
+const COMPLETED_LABEL = "Completed";
+const STOPPED_LABEL = "Stopped";
+
+/**
+ * Map a run's status to a display badge + busy flag. `running` → warning +
+ * spinner, `completed` → success, `stopped` → neutral. Mirrors the LSP/MCP
+ * status visual treatment.
+ */
+export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } {
+ switch (status) {
+ case "running":
+ return { badge: "warning", busy: true };
+ case "completed":
+ return { badge: "success", busy: false };
+ case "stopped":
+ return { badge: "neutral", busy: false };
+ }
+}
+
+export function statusLabelFor(status: HeartbeatRunStatus): string {
+ switch (status) {
+ case "running":
+ return RUNNING_LABEL;
+ case "completed":
+ return COMPLETED_LABEL;
+ case "stopped":
+ return STOPPED_LABEL;
+ }
+}
+
+/**
+ * Format an ISO timestamp as a short absolute clock label (HH:MM:SS) in the
+ * viewer's locale. Returns "—" for an unparseable timestamp so the UI never
+ * crashes on a malformed backend value. Pure (no `now` needed — an absolute
+ * clock label doesn't depend on the current time).
+ */
+export function formatRunTime(triggeredAt: string): string {
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ return clockLabel(t);
+}
+
+/**
+ * A coarse relative label — "just now" (<1m), "Nm ago", "Nh ago", else the
+ * absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`.
+ */
+export function relativeLabel(triggeredAt: string, now: number = Date.now()): string {
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ const deltaMs = now - t;
+ if (deltaMs < 0) return "just now";
+ const mins = Math.floor(deltaMs / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return dateLabel(t);
+}
+
+/**
+ * Build a display view for a run. `now` is injectable for tests (defaults to
+ * `Date.now()`); the composition-root component passes nothing in production.
+ */
+export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView {
+ const { badge, busy } = badgeForStatus(run.status);
+ return {
+ id: run.id,
+ conversationId: run.conversationId,
+ status: run.status,
+ statusLabel: statusLabelFor(run.status),
+ badge,
+ busy,
+ timeLabel: formatRunTime(run.triggeredAt),
+ relativeLabel: relativeLabel(run.triggeredAt, now),
+ };
+}
+
+export function viewRuns(
+ runs: readonly HeartbeatRun[],
+ now: number = Date.now(),
+): readonly HeartbeatRunView[] {
+ return runs.map((r) => viewRun(r, now));
+}
+
+// ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ─────
+
+/** Parse an ISO timestamp to epoch ms, or null if unparseable. */
+function parseTime(iso: string): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */
+function clockLabel(epochMs: number): string {
+ const d = new Date(epochMs);
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ const ss = String(d.getSeconds()).padStart(2, "0");
+ return `${hh}:${mm}:${ss}`;
+}
+
+/** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */
+function dateLabel(epochMs: number): string {
+ const d = new Date(epochMs);
+ const month = d.toLocaleString(undefined, { month: "short" });
+ const day = d.getDate();
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ return `${month} ${day}, ${hh}:${mm}`;
+}
+
+// ── Next-run countdown (timer of when the next heartbeat fires) ───────────────
+//
+// The authoritative next-run time comes from the backend
+// (`GET /workspaces/:id/heartbeat/next-run` → `nextRunAt` ISO string); the FE
+// computes a live countdown from it + a 1s clock. When that endpoint is absent,
+// the FE falls back to an approximation (`approximateNextRunEpoch`) from the
+// latest run + the configured interval.
+
+/** Parse an ISO timestamp to epoch-ms, or null if unparseable. */
+export function nextRunEpoch(iso: string | null | undefined): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/**
+ * Format a remaining-ms delta as a short countdown: "4m 32s", "32s", "1h 05m",
+ * "due" (≤ 0), or "—" (unknown/null). Pure via the injected `remainingMs`.
+ */
+export function formatCountdown(remainingMs: number | null): string {
+ if (remainingMs === null) return "—";
+ if (remainingMs <= 0) return "due";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * Approximate the next-run epoch-ms when the backend's `next-run` endpoint is
+ * unavailable: the LATEST run's `triggeredAt` + `intervalMinutes` (only when the
+ * heartbeat is enabled AND at least one run exists). Returns null otherwise (the
+ * FE then shows no countdown — never a fabricated one). The latest run is the
+ * max `triggeredAt` (runs need not be ordered). Pure (no `now` needed — the next
+ * run is latest + interval, independent of the current time).
+ */
+export function approximateNextRunEpoch(
+ runs: readonly HeartbeatRun[],
+ intervalMinutes: number,
+ enabled: boolean,
+): number | null {
+ if (!enabled) return null;
+ let latest: number | null = null;
+ for (const r of runs) {
+ const t = Date.parse(r.triggeredAt);
+ if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t;
+ }
+ if (latest === null) return null;
+ return latest + intervalMinutes * 60_000;
+}
+
+// ── Config form ───────────────────────────────────────────────────────────────
+
+/**
+ * The editable form state for the config panel — a mutable mirror of a loaded
+ * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to
+ * an effective level for the `<select>` (null ⇒ default `high`), exactly like
+ * the per-conversation selector.
+ *
+ * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the
+ * UI (two inputs), and recombined to a total-minutes value at the patch seam
+ * (`patchFromForm`); the backend stores a single `intervalMinutes`.
+ */
+export interface HeartbeatFormState {
+ enabled: boolean;
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalHours: number;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort;
+}
+
+/** The default interval (minutes) shown for an empty/unset config. */
+export const DEFAULT_INTERVAL_MINUTES = 30;
+
+/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */
+export function splitInterval(totalMinutes: number): { hours: number; minutes: number } {
+ const total = normalizeInterval(totalMinutes);
+ const hours = Math.floor(total / 60);
+ const minutes = total - hours * 60;
+ return { hours, minutes };
+}
+
+/** Recombine hours + minutes into a clamped total-minutes value. Pure. */
+export function joinInterval(hours: number, minutes: number): number {
+ const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
+ const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0;
+ return normalizeInterval(h * 60 + m);
+}
+
+/**
+ * Seed the editable form state from a loaded config, applying safe defaults for
+ * any malformed/absent backend field so the inputs are never `undefined`.
+ */
+export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return {
+ enabled: config.enabled === true,
+ systemPrompt: config.systemPrompt ?? "",
+ taskPrompt: config.taskPrompt ?? "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: typeof config.model === "string" ? config.model : "",
+ reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
+ };
+}
+
+/** An empty form (before the config loads). */
+export function emptyForm(): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
+ return {
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: "",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
+ };
+}
+
+/** Clamp a raw interval to a sane positive-minute range (1–1440 = 1 min–24 h). */
+export function normalizeInterval(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES;
+ const int = Math.round(n);
+ if (int < 1) return 1;
+ if (int > 1440) return 1440;
+ return int;
+}
+
+/**
+ * The patch to PUT when persisting the form. The split hours+minutes are
+ * recombined into a single `intervalMinutes` (clamped); text fields are sent
+ * verbatim. `reasoningEffort` is always present (a resolved level) since the
+ * heartbeat has no per-run override — it persists the level.
+ */
+export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
+ return {
+ enabled: form.enabled,
+ systemPrompt: form.systemPrompt,
+ taskPrompt: form.taskPrompt,
+ intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
+ model: form.model,
+ reasoningEffort: form.reasoningEffort,
+ };
+}
+
+/** Whether the form differs from the loaded config (drives the Save button). */
+export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean {
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return (
+ form.enabled !== config.enabled ||
+ form.systemPrompt !== (config.systemPrompt ?? "") ||
+ form.taskPrompt !== (config.taskPrompt ?? "") ||
+ form.intervalHours !== hours ||
+ form.intervalMinutes !== minutes ||
+ form.model !== (typeof config.model === "string" ? config.model : "") ||
+ form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
+ );
+}
+
+// ── System-prompt inheritance (heartbeat override ⇄ global default) ────────────
+//
+// The heartbeat's `systemPrompt` is an OVERRIDE of the global system prompt (the
+// one every workspace conversation uses — there is no per-workspace system
+// prompt; `GET /system-prompt` is global). An EMPTY override means "inherit the
+// global default" (server-owned resolution: the backend resolves empty → global
+// at run time; see CR-HB-2). These pure helpers keep the override/inherit
+// semantics in ONE place so the editor + form agree.
+
+/**
+ * The prompt to DISPLAY: the heartbeat's override if it set one, else the global
+ * default. The editor pre-fills the textarea with this so the user can see (and
+ * tweak) what will run — but a pre-filled default is NOT an explicit edit.
+ */
+export function effectiveSystemPrompt(override: string, defaultPrompt: string): string {
+ return override !== "" ? override : defaultPrompt;
+}
+
+/** Whether the heartbeat is inheriting the global default (empty override). */
+export function isInheritingSystemPrompt(override: string): boolean {
+ return override === "";
+}
+
+/**
+ * The `systemPrompt` value to PERSIST for the given editable text: if the user's
+ * text matches the global default (or is empty), persist `""` to INHERIT (so a
+ * later change to the global default still flows through); otherwise persist the
+ * text verbatim as an override. This keeps "matching the default = inheriting it"
+ * — never duplicating the default into the heartbeat config.
+ */
+export function persistedSystemPrompt(editable: string, defaultPrompt: string): string {
+ if (editable === "" || editable === defaultPrompt) return "";
+ return editable;
+}
+
+// The reasoning-effort `<option>`s are reused verbatim from the per-conversation
+// selector (re-exported so the config panel imports a single source).
+export { effortOptions };
+
+// ── Network-seam normalization (pure; called by the composition root) ────────
+//
+// The heartbeat API is untyped JSON (not a transport-contract type), so the
+// store coerces each response defensively HERE (pure + tested) — a malformed/
+// partial backend value can never crash the renderer. Mirrors the inline
+// `Array.isArray(data.servers) ? … : []` guard the store does for LSP/MCP.
+
+/** Narrow an untrusted string to the run-status enum, defaulting to "completed". */
+function asRunStatus(value: unknown): HeartbeatRunStatus {
+ if (value === "running" || value === "completed" || value === "stopped") return value;
+ return "completed";
+}
+
+/** Coerce an untrusted `GET .../heartbeat/runs` body into a typed run list. */
+export function normalizeHeartbeatRuns(data: unknown): readonly HeartbeatRun[] {
+ if (!isRecord(data) || !Array.isArray(data.runs)) return [];
+ const runs = data.runs as readonly unknown[];
+ return runs
+ .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object")
+ .map((r) => ({
+ id: typeof r.id === "string" ? r.id : "",
+ conversationId: typeof r.conversationId === "string" ? r.conversationId : "",
+ triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "",
+ status: asRunStatus(r.status),
+ }))
+ .filter((r) => r.id !== "" && r.conversationId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT .../heartbeat` body into a typed config. */
+export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
+ const d = isRecord(data) ? data : {};
+ const effort = d.reasoningEffort;
+ return {
+ enabled: d.enabled === true,
+ systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
+ taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
+ intervalMinutes: normalizeInterval(d.intervalMinutes),
+ model: typeof d.model === "string" ? d.model : "",
+ reasoningEffort:
+ effort === "low" ||
+ effort === "medium" ||
+ effort === "high" ||
+ effort === "xhigh" ||
+ effort === "max"
+ ? effort
+ : null,
+ };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte
new file mode 100644
index 0000000..5f262f8
--- /dev/null
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -0,0 +1,536 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import { isReasoningEffort } from "../../chat/reasoning-effort";
+ import {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ type Badge,
+ emptyForm,
+ effortOptions,
+ formatCountdown,
+ formDiffers,
+ formFromConfig,
+ joinInterval,
+ nextRunEpoch,
+ patchFromForm,
+ viewRuns,
+ type HeartbeatFormState,
+ type HeartbeatRunView,
+ } from "../logic/view-model";
+ import type {
+ HeartbeatRun,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+ } from "../logic/types";
+ import type {
+ LoadSystemPrompt,
+ LoadSystemPromptVariables,
+ } from "../../system-prompt";
+ import PromptEditor from "./PromptEditor.svelte";
+
+ let {
+ models,
+ loadConfig,
+ saveConfig,
+ loadRuns,
+ stopRun,
+ loadVariables,
+ loadDefaultPrompt,
+ loadNextRun,
+ onOpenRun,
+ }: {
+ /** The available model names (for the config's model dropdown). */
+ models: readonly string[];
+ loadConfig: LoadHeartbeatConfig;
+ saveConfig: SaveHeartbeatConfig;
+ loadRuns: LoadHeartbeatRuns;
+ stopRun: StopHeartbeatRun;
+ /** Load the available system-prompt variables (palette in the prompt editor). */
+ loadVariables: LoadSystemPromptVariables;
+ /** Load the global system prompt — the default the heartbeat inherits when
+ * its `systemPrompt` is empty (the workspace's regular prompt). */
+ loadDefaultPrompt: LoadSystemPrompt;
+ /** Load the server-authoritative next-run timestamp (the countdown source). */
+ loadNextRun: LoadHeartbeatNextRun;
+ /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */
+ onOpenRun: (run: HeartbeatRunView) => void;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ const effortOpts = effortOptions();
+
+ // ── Config form ──────────────────────────────────────────────────────────
+ let form = $state<HeartbeatFormState>(emptyForm());
+ /** The last successfully loaded/saved config, to diff the form against. */
+ let loadedConfig = $state<HeartbeatFormState>(emptyForm());
+ let configLoading = $state(false);
+ let configError = $state<string | null>(null);
+ let saving = $state(false);
+ let saveError = $state<string | null>(null);
+ let justSaved = $state(false);
+ let hasConfig = $state(false);
+ let promptEditorOpen = $state(false);
+
+ const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig);
+
+ async function refreshConfig(): Promise<void> {
+ configLoading = true;
+ configError = null;
+ const result = await loadConfig();
+ configLoading = false;
+ if (result === null) return;
+ if (result.ok) {
+ hasConfig = true;
+ form = formFromConfig(result.config);
+ loadedConfig = formFromConfig(result.config);
+ saveError = null;
+ } else {
+ configError = result.error;
+ }
+ }
+
+ async function handleSave(): Promise<void> {
+ if (saving || !hasChanges) return;
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig(patchFromForm(form));
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ // Re-seed from the authoritative response so the form tracks the server.
+ form = formFromConfig(result.config);
+ loadedConfig = formFromConfig(result.config);
+ justSaved = true;
+ } else {
+ saveError = result.error;
+ }
+ }
+
+ // The enable toggle is the primary action — persist it immediately (don't
+ // require a separate Save). Mirrors the codebase's save-on-change controls.
+ async function handleToggleEnabled(): Promise<void> {
+ if (saving) return;
+ const next = !form.enabled;
+ form = { ...form, enabled: next };
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig({ enabled: next });
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ form = formFromConfig(result.config);
+ loadedConfig = formFromConfig(result.config);
+ justSaved = true;
+ } else {
+ saveError = result.error;
+ // Revert the toggle to the last-known state.
+ form = { ...form, enabled: loadedConfig.enabled };
+ }
+ }
+
+ // ── Runs list (polls while mounted) ───────────────────────────────────────
+ let runs = $state<readonly HeartbeatRunView[]>([]);
+ /** The raw backend runs (carry `triggeredAt`), kept for the next-run
+ * approximation fallback (the view drops `triggeredAt` for display labels). */
+ let rawRuns = $state<readonly HeartbeatRun[]>([]);
+ /** True after the first successful load (gates the "No runs yet" empty state
+ * WITHOUT flashing it before the initial fetch resolves). The per-poll
+ * loading is intentionally INVISIBLE — it's near-instant and a visible
+ * loading indicator caused the sidebar to flicker every poll (height shift). */
+ let hasLoadedRuns = $state(false);
+ let runsError = $state<string | null>(null);
+ let stoppingId = $state<string | null>(null);
+ let stopError = $state<string | null>(null);
+ let pollHandle: ReturnType<typeof setInterval> | null = null;
+ /** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */
+ let refreshInFlight = false;
+
+ // ── Next-run countdown ───────────────────────────────────────────────────
+ /** Epoch-ms of the next scheduled run, or null (no countdown shown). Sourced
+ * from the backend's `next-run` endpoint; falls back to an approximation
+ * (latest run + interval) when the endpoint is unavailable (404 — pre-CR-HB-3). */
+ let nextRunAt = $state<number | null>(null);
+ /** Once the next-run endpoint fails (404), stop polling it (avoid 404 spam) and
+ * rely on the approximation. Reset only on remount. */
+ let nextRunEndpointFailed = $state(false);
+
+ async function refreshNextRun(): Promise<void> {
+ if (nextRunEndpointFailed) return;
+ const result = await loadNextRun();
+ if (result === null) return;
+ if (result.ok) {
+ nextRunAt = nextRunEpoch(result.nextRunAt);
+ } else {
+ // Endpoint absent / errored → stop polling it + use the approximation.
+ nextRunEndpointFailed = true;
+ }
+ }
+
+ /** The fallback countdown source: latest run + interval (only when enabled +
+ * ≥1 run). Recomputed reactively from the loaded config + raw runs. */
+ const approxNextRun = $derived(
+ approximateNextRunEpoch(
+ rawRuns,
+ joinInterval(loadedConfig.intervalHours, loadedConfig.intervalMinutes),
+ loadedConfig.enabled,
+ ),
+ );
+ /** The effective next-run epoch: the server value if available, else the
+ * approximation. Drives the countdown. */
+ const effectiveNextRun = $derived(nextRunEndpointFailed ? approxNextRun : nextRunAt);
+
+ const RUN_POLL_MS = 4000;
+
+ async function refreshRuns(): Promise<void> {
+ if (refreshInFlight) return;
+ refreshInFlight = true;
+ const result = await loadRuns();
+ refreshInFlight = false;
+ if (result === null) return;
+ if (result.ok) {
+ rawRuns = result.runs;
+ runs = viewRuns(result.runs);
+ // Clear the error only on success so it stays visible (stable, no
+ // flicker) during an in-flight retry rather than vanishing mid-poll.
+ runsError = null;
+ hasLoadedRuns = true;
+ } else {
+ runsError = result.error;
+ }
+ }
+
+ async function handleStop(runId: string): Promise<void> {
+ if (stoppingId !== null) return;
+ stoppingId = runId;
+ stopError = null;
+ const result = await stopRun(runId);
+ stoppingId = null;
+ if (result === null) return;
+ if (result.ok) {
+ await refreshRuns();
+ } else {
+ stopError = result.error;
+ }
+ }
+
+ // Load config + runs + next-run on mount, and poll them while the view is
+ // alive so a running run's completion/stopped transition + the next-run timer
+ // stay fresh without a manual refresh.
+ $effect(() => {
+ untrack(() => {
+ void refreshConfig();
+ void refreshRuns();
+ void refreshNextRun();
+ });
+ pollHandle = setInterval(() => {
+ void refreshRuns();
+ void refreshNextRun();
+ }, RUN_POLL_MS);
+ return () => {
+ if (pollHandle !== null) clearInterval(pollHandle);
+ pollHandle = null;
+ };
+ });
+
+ // A relative label ("5m ago") drifts as time passes; re-derive runs every
+ // minute so the list stays fresh without a full re-fetch.
+ let tick = $state(0);
+ $effect(() => {
+ const h = setInterval(() => {
+ tick++;
+ }, 60000);
+ return () => clearInterval(h);
+ });
+ const runsView = $derived.by(() => {
+ void tick; // depend on the ticker
+ return runs;
+ });
+
+ // The countdown clock: ticks every second so the "next run in Xm Ys" stays
+ // live. Pure countdown math is in `formatCountdown` (view-model); this only
+ // advances `now`.
+ let now = $state(Date.now());
+ $effect(() => {
+ const h = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(h);
+ });
+ const countdownMs = $derived(
+ effectiveNextRun !== null ? effectiveNextRun - now : null,
+ );
+ const countdownLabel = $derived(formatCountdown(countdownMs));
+</script>
+
+<div class="flex flex-col gap-3">
+ <!-- Enable / status header -->
+ <section class="flex flex-col gap-1">
+ <div class="flex items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ role="switch"
+ aria-checked={form.enabled}
+ aria-label="Toggle heartbeat"
+ class="toggle toggle-sm"
+ class:toggle-primary={form.enabled}
+ disabled={saving || configLoading}
+ onclick={handleToggleEnabled}
+ ></button>
+ <span class="text-xs font-semibold uppercase opacity-60">
+ {#if configLoading}
+ Loading…
+ {:else if form.enabled}
+ Enabled
+ {:else}
+ Disabled
+ {/if}
+ </span>
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={configLoading}
+ onclick={() => refreshConfig()}
+ aria-label="Refresh heartbeat config"
+ >
+ {#if configLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+ {#if form.enabled && effectiveNextRun !== null}
+ <p class="text-xs opacity-60" title="When the next heartbeat run fires">
+ Next run in {countdownLabel}
+ </p>
+ {/if}
+ </section>
+
+ {#if configError}
+ <p class="text-xs text-error">{configError}</p>
+ {:else}
+ <!-- Prompts (open the full-page editor) -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Prompts</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={saving || configLoading}
+ onclick={() => (promptEditorOpen = true)}
+ >
+ Edit prompts
+ </button>
+ <p class="text-xs opacity-50">
+ Open the editor for the system + task prompts (with a variable palette).
+ </p>
+ </section>
+
+ <!-- Model + reasoning effort -->
+ <section class="flex flex-col gap-2">
+ <div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Model</span>
+ <select
+ class="select select-sm w-full"
+ value={form.model}
+ disabled={saving || configLoading}
+ onchange={(e) => (form = { ...form, model: e.currentTarget.value })}
+ aria-label="Heartbeat model"
+ >
+ {#if models.length === 0}
+ <option value="">No models available</option>
+ {:else}
+ <option value="" disabled>Select a model</option>
+ {#each models as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ {/if}
+ </select>
+ </div>
+
+ <div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
+ <select
+ class="select select-sm w-full"
+ value={form.reasoningEffort}
+ disabled={saving || configLoading}
+ onchange={(e) => {
+ const v = e.currentTarget.value;
+ if (isReasoningEffort(v)) form = { ...form, reasoningEffort: v as ReasoningEffort };
+ }}
+ aria-label="Heartbeat reasoning effort"
+ >
+ {#each effortOpts as option (option.value)}
+ <option value={option.value}>{option.label}</option>
+ {/each}
+ </select>
+ </div>
+ </section>
+
+ <!-- Interval (hours + minutes) -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Interval</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-20"
+ min="0"
+ max="24"
+ value={form.intervalHours}
+ disabled={saving || configLoading}
+ oninput={(e) => {
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ form = { ...form, intervalHours: Number.isNaN(n) ? 0 : n };
+ }}
+ onchange={(e) => {
+ const clamped = Math.max(0, Math.min(24, form.intervalHours));
+ form = { ...form, intervalHours: clamped };
+ e.currentTarget.value = String(clamped);
+ }}
+ aria-label="Heartbeat interval hours"
+ />
+ <span class="text-xs opacity-60">h</span>
+ <input
+ type="number"
+ class="input input-bordered input-sm w-20"
+ min="0"
+ max="59"
+ value={form.intervalMinutes}
+ disabled={saving || configLoading}
+ oninput={(e) => {
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ form = { ...form, intervalMinutes: Number.isNaN(n) ? 0 : n };
+ }}
+ onchange={(e) => {
+ const clamped = Math.max(0, Math.min(59, form.intervalMinutes));
+ form = { ...form, intervalMinutes: clamped };
+ e.currentTarget.value = String(clamped);
+ }}
+ aria-label="Heartbeat interval minutes"
+ />
+ <span class="text-xs opacity-60">m between runs</span>
+ </div>
+ </section>
+
+ <!-- Save -->
+ <section class="flex flex-col gap-1">
+ <button
+ type="button"
+ class="btn btn-sm btn-primary"
+ disabled={!hasChanges || saving || configLoading}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ Saving…
+ {:else}
+ Save config
+ {/if}
+ </button>
+ {#if saveError}
+ <p class="text-xs text-error">{saveError}</p>
+ {:else if justSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
+ {/if}
+
+ <!-- Runs list -->
+ <section class="flex flex-col gap-1">
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs font-semibold uppercase opacity-60">Runs</span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => refreshRuns()}
+ aria-label="Refresh heartbeat runs"
+ >
+ Refresh
+ </button>
+ </div>
+
+ {#if runsError}
+ <p class="text-xs text-error">{runsError}</p>
+ {:else if runs.length > 0}
+ <ul class="flex max-h-72 flex-col gap-1 overflow-y-auto">
+ {#each runsView as run (run.id)}
+ <li>
+ <button
+ type="button"
+ class="flex w-full items-center justify-between gap-2 rounded-box bg-base-200 p-2 text-left hover:bg-base-300"
+ onclick={() => onOpenRun(run)}
+ aria-label="Open heartbeat run {run.id} chat"
+ >
+ <span class="flex min-w-0 flex-col gap-0.5">
+ <span class="truncate font-mono text-xs opacity-70">{run.id}</span>
+ <span class="text-xs opacity-60">
+ {run.relativeLabel} · {run.timeLabel}
+ </span>
+ </span>
+ <span class="flex items-center gap-1">
+ {#if run.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ <span class="badge badge-sm {badgeClass[run.badge]}">{run.statusLabel}</span>
+ </span>
+ </button>
+ {#if run.busy}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs mt-0.5 text-xs"
+ disabled={stoppingId === run.id}
+ onclick={() => handleStop(run.id)}
+ >
+ {#if stoppingId === run.id}
+ <span class="loading loading-spinner loading-xs"></span>
+ Stopping…
+ {:else}
+ Stop
+ {/if}
+ </button>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {#if stopError}
+ <p class="text-xs text-error">{stopError}</p>
+ {/if}
+ {:else if hasLoadedRuns}
+ <!-- Loaded with zero runs (not the pre-first-load gap). No loading
+ indicator — polling is near-instant and a visible one flickered. -->
+ <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p>
+ {/if}
+ </section>
+</div>
+
+{#if promptEditorOpen}
+ <PromptEditor
+ systemPrompt={form.systemPrompt}
+ taskPrompt={form.taskPrompt}
+ {loadVariables}
+ {loadDefaultPrompt}
+ {saveConfig}
+ onSaved={(systemPrompt, taskPrompt) => {
+ // Sync the form + the diff baseline so the main Save button + formDiffers
+ // stay accurate (the editor persisted the prompts already). `systemPrompt`
+ // may be "" (inherit) — the form stores the raw override.
+ form = { ...form, systemPrompt, taskPrompt };
+ loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt };
+ justSaved = true;
+ }}
+ onClose={() => (promptEditorOpen = false)}
+ />
+{/if}
diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte
new file mode 100644
index 0000000..2320827
--- /dev/null
+++ b/src/features/heartbeat/ui/PromptEditor.svelte
@@ -0,0 +1,412 @@
+<script lang="ts">
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPrompt,
+ type LoadSystemPromptVariables,
+ } from "../../system-prompt";
+ import type { SaveHeartbeatConfig } from "../logic/types";
+ import {
+ effectiveSystemPrompt,
+ isInheritingSystemPrompt,
+ persistedSystemPrompt,
+ } from "../logic/view-model";
+ import { portal } from "../../../adapters/portal";
+
+ let {
+ systemPrompt,
+ taskPrompt,
+ loadVariables,
+ loadDefaultPrompt,
+ saveConfig,
+ onSaved,
+ onClose,
+ }: {
+ /**
+ * The heartbeat's persisted system prompt (raw override). Empty = inherit
+ * the global system prompt (the workspace's regular prompt).
+ */
+ systemPrompt: string;
+ /** The current task prompt (seeded from the loaded config). */
+ taskPrompt: string;
+ /** Load the available variables (`GET /system-prompt/variables`). */
+ loadVariables: LoadSystemPromptVariables;
+ /** Load the GLOBAL system prompt (`GET /system-prompt`) — the default the
+ * heartbeat inherits when its `systemPrompt` is empty. */
+ loadDefaultPrompt: LoadSystemPrompt;
+ /** Persist both prompts via a partial heartbeat config PUT. */
+ saveConfig: SaveHeartbeatConfig;
+ /** Called after a successful save with the RAW persisted prompts (system
+ * may be "" = inherit), so the parent can sync its form. */
+ onSaved: (systemPrompt: string, taskPrompt: string) => void;
+ onClose: () => void;
+ } = $props();
+
+ // The global default system prompt (loaded async on open). Empty until loaded
+ // (or when no global prompt is configured) — the editor degrades gracefully.
+ let defaultPrompt = $state("");
+
+ // The editable system text. Pre-filled with the EFFECTIVE prompt — the
+ // heartbeat's override, or the global default when inheriting (so the user
+ // can see + tweak what will run). A pre-filled default is NOT an explicit
+ // edit (see `hasChanges`).
+ let system = $state(untrack(() => systemPrompt));
+ let task = $state(untrack(() => taskPrompt));
+
+ // The raw persisted override at open + after each save (the diff baseline for
+ // the system field). Empty = the heartbeat is inheriting the global default.
+ // REACTIVE so a successful save can update it to the newly-persisted value —
+ // otherwise `systemBaseline` stays pinned to the open-time value and
+ // `hasChanges` never clears (the "Save flickers and reverts" bug).
+ let loadedSystemRaw = $state(untrack(() => systemPrompt));
+ let loadedTask = $state(untrack(() => taskPrompt));
+
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let varsLoading = $state(false);
+ let varsError = $state<string | null>(null);
+ let defaultLoading = $state(false);
+
+ let saving = $state(false);
+ let saveError = $state<string | null>(null);
+ let justSaved = $state(false);
+
+ // The textarea currently focused — variable insertion targets THIS one.
+ type Field = "system" | "task";
+ let activeField = $state<Field>("system");
+ let systemEl = $state<HTMLTextAreaElement | null>(null);
+ let taskEl = $state<HTMLTextAreaElement | null>(null);
+
+ const groups = $derived(groupVariables(variables));
+ /** The baseline system text to diff against: the effective prompt at open +
+ * after the last save (override, or the default when inheriting) — so a
+ * pre-filled default does NOT register as an unsaved change, and a saved
+ * edit clears `hasChanges` (the baseline tracks the persisted value). */
+ const systemBaseline = $derived(effectiveSystemPrompt(loadedSystemRaw, defaultPrompt));
+ const hasChanges = $derived(system !== systemBaseline || task !== loadedTask);
+ /** Whether the current text matches the default (i.e. saving would inherit). */
+ const inheriting = $derived(system === defaultPrompt && defaultPrompt !== "");
+
+ async function loadVars(): Promise<void> {
+ untrack(() => {
+ varsLoading = true;
+ varsError = null;
+ });
+ const result = await loadVariables();
+ varsLoading = false;
+ if (result.ok) {
+ variables = result.variables;
+ } else {
+ varsError = result.error;
+ }
+ }
+
+ async function loadDefault(): Promise<void> {
+ untrack(() => {
+ defaultLoading = true;
+ });
+ const result = await loadDefaultPrompt();
+ defaultLoading = false;
+ if (result.ok) {
+ defaultPrompt = result.template;
+ // Pre-fill an inheriting (empty) override with the global default so the
+ // user can see + tweak what will run — but ONLY if they haven't edited
+ // the system field yet (system still equals the open-time raw override).
+ // Done here (not in a reactive $effect) so a late-loading default can't
+ // clobber an in-flight edit.
+ if (isInheritingSystemPrompt(loadedSystemRaw) && system === loadedSystemRaw) {
+ system = defaultPrompt;
+ }
+ }
+ // A failed default load is non-fatal: the editor still works with the
+ // raw override; only the "inherit" affordance is unavailable.
+ }
+
+ async function save(): Promise<void> {
+ if (saving || !hasChanges) return;
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ // Persist the system prompt via the inheritance helper: matching the
+ // default (or empty) → "" (inherit); otherwise the override verbatim.
+ const systemToPersist = persistedSystemPrompt(system, defaultPrompt);
+ const result = await saveConfig({ systemPrompt: systemToPersist, taskPrompt: task });
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ // Advance the diff baseline to the persisted value so `hasChanges`
+ // clears (systemBaseline recomputes off loadedSystemRaw). Without this
+ // the baseline stays pinned to the open-time value and the Save button
+ // never settles ("flickers and reverts to unsaved").
+ loadedSystemRaw = systemToPersist;
+ loadedTask = task;
+ justSaved = true;
+ onSaved(systemToPersist, task);
+ } else {
+ saveError = result.error;
+ }
+ }
+
+ /** Revert ALL edits to the open-time state (system effective prompt + task). */
+ function reset(): void {
+ system = systemBaseline;
+ task = loadedTask;
+ saveError = null;
+ justSaved = false;
+ }
+
+ /** Reset ONLY the system prompt to the global default (clears any override →
+ * inherit on save). No-op until the default has loaded. */
+ function resetSystemToDefault(): void {
+ if (defaultPrompt === "") return;
+ system = defaultPrompt;
+ saveError = null;
+ justSaved = false;
+ }
+
+ /**
+ * Insert a variable tag into the ACTIVE textarea at its cursor. The active
+ * field is tracked via focus handlers; insertion uses that field's element +
+ * its own text (so a tag never lands in the wrong box).
+ */
+ async function insertAtActive(tag: string): Promise<void> {
+ const el = activeField === "system" ? systemEl : taskEl;
+ if (el === null) return;
+ const start = el.selectionStart;
+ const end = el.selectionEnd;
+ if (activeField === "system") {
+ const ins = insertTag(system, tag, start, end);
+ system = ins.template;
+ await tick();
+ el.focus();
+ el.setSelectionRange(ins.cursor, ins.cursor);
+ } else {
+ const ins = insertTag(task, tag, start, end);
+ task = ins.template;
+ await tick();
+ el.focus();
+ el.setSelectionRange(ins.cursor, ins.cursor);
+ }
+ }
+
+ /** Dynamic (file:<path>) variable: build the tag from the input + insert. */
+ async function insertDynamic(type: string, path: string): Promise<void> {
+ const trimmed = path.trim();
+ if (trimmed.length === 0) return;
+ await insertAtActive(buildTag(type, trimmed));
+ }
+
+ function onKeydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load the variable palette + the global default once on open.
+ $effect(() => {
+ void loadVars();
+ void loadDefault();
+ });
+</script>
+
+<svelte:window onkeydown={onKeydown} />
+
+<!-- Teleported to <body> (use:portal) so `position: fixed` resolves against the
+ VIEWPORT, not the sidebar's `transform: translateX(...)` container — an
+ ancestor transform establishes a containing block for `fixed`, which would
+ otherwise clip this overlay to the sidebar area. (RunModal/SystemPromptBuilder
+ avoid this by rendering at the composition root; this modal lives inside
+ HeartbeatView, so it must escape its ancestor.) -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div
+ use:portal
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="Heartbeat prompt editor"
+ 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">Heartbeat Prompts</h2>
+ {#if varsLoading}
+ <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 prompt editor"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor (two boxes) / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- Left: two text editors (system top, task bottom) -->
+ <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
+ <div class="flex min-h-0 flex-1 flex-col gap-1">
+ <div class="flex shrink-0 items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <span class="text-xs font-semibold uppercase opacity-60">System prompt</span>
+ {#if defaultLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else if inheriting}
+ <span class="badge badge-ghost badge-sm font-normal">Inheriting workspace default</span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={defaultPrompt === "" || saving}
+ onclick={resetSystemToDefault}
+ title="Reset the system prompt to the workspace default (inherit)"
+ >
+ Reset to default
+ </button>
+ </div>
+ <textarea
+ bind:this={systemEl}
+ bind:value={system}
+ onfocus={() => (activeField = "system")}
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder={defaultPrompt || "You are an autonomous agent…"}
+ disabled={saving}
+ aria-label="Heartbeat system prompt"
+ ></textarea>
+ <p class="shrink-0 text-xs opacity-50">
+ {#if inheriting}
+ Matches the workspace default — saving will inherit it (no override).
+ {:else if defaultPrompt !== ""}
+ Editing overrides the workspace default.
+ {:else}
+ Empty — no system prompt set.
+ {/if}
+ </p>
+ </div>
+
+ <div class="flex min-h-0 flex-1 flex-col gap-1">
+ <span class="shrink-0 text-xs font-semibold uppercase opacity-60">Task prompt</span>
+ <textarea
+ bind:this={taskEl}
+ bind:value={task}
+ onfocus={() => (activeField = "task")}
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder="Check the system status and report…"
+ disabled={saving}
+ aria-label="Heartbeat task prompt"
+ ></textarea>
+ </div>
+
+ <div class="flex shrink-0 flex-wrap items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={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={!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 saveError}
+ <p class="shrink-0 text-xs text-error">{saveError}</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>
+ <p class="mb-3 shrink-0 text-xs opacity-50">
+ Click a variable to insert it into the focused prompt box.
+ </p>
+ {#if varsError}
+ <p class="text-xs text-error">{varsError}</p>
+ {:else if groups.length === 0 && !varsLoading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {:else}
+ <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)}
+ <!-- Dynamic (file:<path>) variable: a path input + Insert button. -->
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ const v = e.currentTarget.value;
+ void insertDynamic(variable.type, v);
+ e.currentTarget.value = "";
+ }
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={(e) => {
+ const input = (e.currentTarget as HTMLButtonElement)
+ .previousElementSibling as HTMLInputElement | null;
+ if (input !== null) {
+ void insertDynamic(variable.type, input.value);
+ input.value = "";
+ }
+ }}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() =>
+ void insertAtActive(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts
new file mode 100644
index 0000000..284b319
--- /dev/null
+++ b/src/features/heartbeat/ui/PromptEditor.test.ts
@@ -0,0 +1,167 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type {
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ SaveHeartbeatConfig,
+} from "../logic/types";
+import PromptEditor from "./PromptEditor.svelte";
+
+// Fakes for the injected ports.
+
+function fakeLoadVariables() {
+ return vi.fn(async () => ({ ok: true, variables: [] }) as const);
+}
+
+function fakeLoadDefaultPrompt(template = "You are a helpful assistant.") {
+ return vi.fn(async () => ({ ok: true, template }) as const);
+}
+
+/** A capturing saveConfig that resolves ok, echoing the merged config shape. */
+function fakeSaveConfig(): {
+ calls: HeartbeatConfigPatch[];
+ impl: SaveHeartbeatConfig;
+} {
+ const calls: HeartbeatConfigPatch[] = [];
+ const impl: SaveHeartbeatConfig = async (patch) => {
+ calls.push(patch);
+ // Echo a config that reflects the persisted patch (so onSaved sync is realistic).
+ const config = {
+ enabled: false,
+ systemPrompt: patch.systemPrompt ?? "",
+ taskPrompt: patch.taskPrompt ?? "",
+ intervalMinutes: 30,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ };
+ return { ok: true, config } satisfies HeartbeatConfigResult;
+ };
+ return { calls, impl };
+}
+
+const baseProps = (overrides: Record<string, unknown> = {}) => ({
+ systemPrompt: "",
+ taskPrompt: "",
+ loadVariables: fakeLoadVariables(),
+ loadDefaultPrompt: fakeLoadDefaultPrompt(),
+ saveConfig: fakeSaveConfig().impl,
+ onSaved: vi.fn(),
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+describe("PromptEditor save flow", () => {
+ it("persists an edited system prompt and clears the unsaved state (regression: save flickered + reverted)", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ const onSaved = vi.fn();
+ render(PromptEditor, {
+ props: baseProps({
+ // Start inheriting (empty override); the default pre-fills.
+ systemPrompt: "",
+ saveConfig: save.impl,
+ onSaved,
+ }),
+ });
+
+ // Wait for the default to load + pre-fill the system textarea.
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ expect(systemBox).toHaveValue("You are a helpful assistant.");
+
+ // Save is disabled while it matches the default (no explicit edit).
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+
+ // Edit the system prompt → an override.
+ await user.clear(systemBox);
+ await user.type(systemBox, "custom override");
+
+ // Save is now enabled.
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ // The save port was called with the override persisted verbatim.
+ expect(save.calls).toHaveLength(1);
+ expect(save.calls[0]?.systemPrompt).toBe("custom override");
+ expect(onSaved).toHaveBeenCalledWith("custom override", "");
+
+ // THE REGRESSION: after save, hasChanges must clear (Save disabled again)
+ // and the "Saved." confirmation shows — NOT "Unsaved changes".
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ expect(screen.queryByText(/Unsaved changes/i)).not.toBeInTheDocument();
+ });
+
+ it("persisting text that matches the default sends '' (inherit) and clears unsaved state", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ render(PromptEditor, {
+ props: baseProps({
+ // Start with an override.
+ systemPrompt: "old override",
+ saveConfig: save.impl,
+ }),
+ });
+
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ expect(systemBox).toHaveValue("old override");
+
+ // Reset to default → text matches the default → saving inherits ("").
+ await user.click(screen.getByRole("button", { name: "Reset to default" }));
+ expect(systemBox).toHaveValue("You are a helpful assistant.");
+
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ expect(save.calls).toHaveLength(1);
+ expect(save.calls[0]?.systemPrompt).toBe(""); // inherit
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ });
+
+ it("editing the task prompt saves + clears unsaved state", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ render(PromptEditor, {
+ props: baseProps({ saveConfig: save.impl }),
+ });
+
+ const taskBox = await screen.findByLabelText("Heartbeat task prompt");
+ await user.type(taskBox, "do the thing");
+
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ expect(save.calls[0]?.taskPrompt).toBe("do the thing");
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ });
+
+ it("a failed save surfaces the error and keeps the edit unsaved", async () => {
+ const user = userEvent.setup();
+ const failingSave: SaveHeartbeatConfig = async () => ({ ok: false, error: "boom" });
+ render(PromptEditor, {
+ props: baseProps({ saveConfig: failingSave }),
+ });
+
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ await user.clear(systemBox);
+ await user.type(systemBox, "custom");
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ // Still unsaved (Save stays enabled), no success badge.
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte
new file mode 100644
index 0000000..92068ae
--- /dev/null
+++ b/src/features/heartbeat/ui/RunModal.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import { tick } from "svelte";
+ import { ChatView } from "../../chat";
+ import type { ChatStore } from "../../chat";
+ import type { HeartbeatRunView } from "../logic/view-model";
+ import type { StopHeartbeatRun } from "../logic/types";
+
+ let {
+ run,
+ openChat,
+ closeChat,
+ stopRun,
+ onClose,
+ apiBaseUrl = "",
+ }: {
+ /** The run to display (its conversation's chat is shown live). */
+ run: HeartbeatRunView;
+ /**
+ * Open a live watch on a conversation (the store's `watchConversation`):
+ * returns a {@link ChatStore} subscribed to the conversation's turn stream
+ * + history loaded. The modal owns the watch lifecycle — calls
+ * `closeChat` on unmount.
+ */
+ openChat: (conversationId: string) => ChatStore;
+ /** Dispose + unsubscribe the watch opened by `openChat`. */
+ closeChat: (conversationId: string) => void;
+ /** Stop the heartbeat run (`POST .../runs/:runId/stop`). */
+ stopRun: StopHeartbeatRun;
+ onClose: () => void;
+ /**
+ * The HTTP API base URL, to resolve persisted image chunk URLs
+ * (`/images/…`) in the run's transcript. Defaults to "" (root-relative).
+ */
+ apiBaseUrl?: string;
+ } = $props();
+
+ // Open the live watch ONCE on mount (the modal is keyed per run.id, so a run
+ // switch remounts it). `untrack` avoids re-running if the prop fn identity
+ // changes — `run.conversationId` is the real dependency, captured once here.
+ let chat = $state<ChatStore | null>(null);
+ $effect(() => {
+ chat = openChat(run.conversationId);
+ return () => closeChat(run.conversationId);
+ });
+
+ // Live scroll: keep the transcript pinned to the bottom while it streams
+ // (unless the reader has scrolled up — then we don't fight them).
+ let scrollEl = $state<HTMLDivElement | undefined>();
+ let contentEl = $state<HTMLDivElement | undefined>();
+ let pinned = $state(true);
+
+ function onScroll() {
+ const el = scrollEl;
+ if (el === undefined) return;
+ pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+ }
+
+ // Follow the bottom on new content while pinned. Reads `chunks.length` so the
+ // effect re-runs on every streamed append.
+ const chunkCount = $derived(chat?.chunks.length ?? 0);
+ $effect(() => {
+ void chunkCount;
+ if (!pinned) return;
+ void tick().then(() => {
+ const el = scrollEl;
+ if (el !== undefined) el.scrollTop = el.scrollHeight;
+ });
+ });
+
+ // Stop state.
+ let stopping = $state(false);
+ let stopError = $state<string | null>(null);
+
+ async function handleStop() {
+ if (stopping) return;
+ stopping = true;
+ stopError = null;
+ const result = await stopRun(run.id);
+ stopping = false;
+ if (result === null) return;
+ if (!result.ok) stopError = result.error;
+ }
+
+ // The live "running" signal: the chat store's `generating` reflects the
+ // actual event stream (turn-start…turn-sealed). True while a turn streams —
+ // that is when a Stop is meaningful. Falls back to the run's status snapshot
+ // before the stream attaches.
+ const live = $derived(chat?.generating ?? run.busy);
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+<!-- Fullscreen overlay. -->
+<div class="fixed inset-0 z-50 flex flex-col bg-base-100">
+ <!-- Header -->
+ <header class="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-2">
+ <div class="flex min-w-0 items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={onClose}
+ aria-label="Close run chat"
+ >
+ ✕
+ </button>
+ <span class="truncate font-mono text-xs opacity-70" title="Run id">{run.id}</span>
+ {#if live}
+ <span class="badge badge-sm badge-warning gap-1">
+ <span class="loading loading-spinner loading-xs"></span>
+ Running
+ </span>
+ {:else}
+ <span class="badge badge-sm badge-ghost">{run.statusLabel}</span>
+ {/if}
+ </div>
+ <div class="flex items-center gap-2">
+ {#if stopError}
+ <span class="text-xs text-error">{stopError}</span>
+ {/if}
+ {#if live}
+ <button
+ type="button"
+ class="btn btn-sm btn-error btn-outline"
+ disabled={stopping}
+ onclick={handleStop}
+ >
+ {#if stopping}
+ <span class="loading loading-spinner loading-xs"></span>
+ Stopping…
+ {:else}
+ Stop
+ {/if}
+ </button>
+ {/if}
+ </div>
+ </header>
+
+ <!-- Transcript -->
+ <div class="relative min-h-0 flex-1">
+ <div bind:this={scrollEl} class="h-full overflow-y-auto" onscroll={onScroll}>
+ <div bind:this={contentEl} class="p-4">
+ {#if chat === null}
+ <div class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-md"></span>
+ </div>
+ {:else if chat.chunks.length === 0 && chat.pendingSync}
+ <div class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-md"></span>
+ </div>
+ {:else}
+ <ChatView
+ chunks={chat.chunks}
+ turnMetrics={chat.turnMetrics}
+ hasEarlier={chat.hasEarlier}
+ onShowEarlier={chat.showEarlier}
+ thinkingKeyBase={chat.thinkingKeyBase}
+ providerRetry={chat.providerRetry}
+ apiBaseUrl={apiBaseUrl}
+ />
+ {/if}
+ </div>
+ </div>
+ {#if chat !== null && chat.chunks.length === 0 && !chat.pendingSync}
+ <div
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
+ aria-hidden="true"
+ >
+ <span class="select-none text-2xl font-bold opacity-10">No messages</span>
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts
new file mode 100644
index 0000000..ff3aefa
--- /dev/null
+++ b/src/features/markdown/index.ts
@@ -0,0 +1,8 @@
+export { renderMarkdown } from "./logic/markdown";
+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)",
+} as const;
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
new file mode 100644
index 0000000..54b0086
--- /dev/null
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -0,0 +1,58 @@
+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("");
+ });
+});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
new file mode 100644
index 0000000..ad8a8bd
--- /dev/null
+++ b/src/features/markdown/logic/markdown.ts
@@ -0,0 +1,165 @@
+/**
+ * Pure Markdown → sanitized-HTML renderer for assistant messages.
+ *
+ * Mirrors old Dispatch's stack (marked + marked-highlight + highlight.js +
+ * DOMPurify; GFM + line breaks; streaming delimiter-closing), but kept fully
+ * SYNCHRONOUS and pure: `input → output`, no effects, no `$effect`. Languages
+ * are a fixed "hot set" registered at module load (no lazy dynamic import), so a
+ * single `renderMarkdown(text)` call is deterministic and unit-testable.
+ *
+ * The only ambient dependency is DOMPurify, which sanitizes against the DOM —
+ * present in the browser and in the jsdom test env.
+ */
+
+import DOMPurify from "dompurify";
+import type { LanguageFn } from "highlight.js";
+import hljs from "highlight.js/lib/core";
+import bash from "highlight.js/lib/languages/bash";
+import c from "highlight.js/lib/languages/c";
+import cpp from "highlight.js/lib/languages/cpp";
+import csharp from "highlight.js/lib/languages/csharp";
+import css from "highlight.js/lib/languages/css";
+import go from "highlight.js/lib/languages/go";
+import java from "highlight.js/lib/languages/java";
+import javascript from "highlight.js/lib/languages/javascript";
+import json from "highlight.js/lib/languages/json";
+import markdownLang from "highlight.js/lib/languages/markdown";
+import php from "highlight.js/lib/languages/php";
+import plaintext from "highlight.js/lib/languages/plaintext";
+import python from "highlight.js/lib/languages/python";
+import ruby from "highlight.js/lib/languages/ruby";
+import rust from "highlight.js/lib/languages/rust";
+import shell from "highlight.js/lib/languages/shell";
+import sql from "highlight.js/lib/languages/sql";
+import typescript from "highlight.js/lib/languages/typescript";
+import xml from "highlight.js/lib/languages/xml";
+import yaml from "highlight.js/lib/languages/yaml";
+import { Marked } from "marked";
+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,
+};
+for (const [name, lang] of Object.entries(HOT_LANGUAGES)) {
+ 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",
+};
+
+function normalizeLang(lang: string): string {
+ 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;");
+}
+
+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 },
+);
+
+/**
+ * While a message is still streaming, balance dangling fences / emphasis so the
+ * 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;
+}
+
+// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
+// copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and
+// escapes `<`/`>` inside code, so these literal tags only ever delimit blocks.
+// `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>';
+
+function addCopyButtons(html: string): string {
+ 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 "";
+ }
+}
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
new file mode 100644
index 0000000..72b892b
--- /dev/null
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -0,0 +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);
+ });
+</script>
+
+<div class="markdown-body" bind:this={container}>
+ <!-- {@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
new file mode 100644
index 0000000..d65b3d1
--- /dev/null
+++ b/src/features/markdown/ui/markdown.test.ts
@@ -0,0 +1,40 @@
+import { fireEvent, render, screen } from "@testing-library/svelte";
+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**" } });
+
+ 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" },
+ });
+
+ 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 });
+
+ 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");
+
+ await fireEvent.click(button);
+
+ 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
new file mode 100644
index 0000000..9abc023
--- /dev/null
+++ b/src/features/mcp/index.ts
@@ -0,0 +1,8 @@
+export type { LoadMcpStatus, McpStatusResult } from "./logic/view-model";
+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",
+} as const;
diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts
new file mode 100644
index 0000000..d1a77d1
--- /dev/null
+++ b/src/features/mcp/logic/view-model.test.ts
@@ -0,0 +1,88 @@
+import type { McpServerInfo } from "@dispatch/transport-contract";
+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,
+});
+
+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("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("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");
+ });
+
+ 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"]);
+ });
+});
+
+describe("summarizeMcpServers", () => {
+ 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("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
new file mode 100644
index 0000000..fdff78a
--- /dev/null
+++ b/src/features/mcp/logic/view-model.ts
@@ -0,0 +1,110 @@
+import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the mcp feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The mcp feature exposes the live status of the MCP (Model Context Protocol)
+ * servers configured for a conversation's working directory, fetched from
+ * `GET /conversations/:id/mcp`. This module holds the pure logic: the mapping
+ * of a backend `McpServerState` to a display badge + label, and a one-line
+ * server summary. The effect (the HTTP get MCP status) is INJECTED via the
+ * `LoadMcpStatus` port below; the composition root implements it.
+ */
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's HTTP call to this shape). ──────────────────────────────────────────
+
+/** 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 };
+
+export type LoadMcpStatus = () => Promise<McpStatusResult | null>;
+
+// ── MCP server status → display view ───────────────────────────────────────────
+
+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;
+}
+
+/**
+ * Map a server's state to a display label + badge severity + busy flag. Mirrors
+ * the LSP status visual treatment: `connected` → success, `connecting` (the
+ * transient state, analogous to LSP's `starting`) → warning + spinner, `error`
+ * → 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,
+ };
+}
+
+export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] {
+ return servers.map(viewMcpServer);
+}
+
+/**
+ * A short one-line summary, e.g. "2 connected" / "1 connected, 1 connecting,
+ * 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(", ");
+}
diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte
new file mode 100644
index 0000000..ad51496
--- /dev/null
+++ b/src/features/mcp/ui/McpStatusView.svelte
@@ -0,0 +1,133 @@
+<script lang="ts">
+ 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();
+
+ 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("");
+
+ 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();
+ });
+ });
+</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>
+
+ {#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
new file mode 100644
index 0000000..3942e97
--- /dev/null
+++ b/src/features/settings/index.ts
@@ -0,0 +1,9 @@
+export type { ChatLimitParse, ChatLimitSaveResult, SaveChatLimit } from "./logic/view-model";
+export { chatLimitChanged, parseChatLimit } from "./logic/view-model";
+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)",
+} as const;
diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts
new file mode 100644
index 0000000..93c4786
--- /dev/null
+++ b/src/features/settings/logic/view-model.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+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) });
+ });
+});
+
+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);
+ });
+});
diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts
new file mode 100644
index 0000000..73f6d17
--- /dev/null
+++ b/src/features/settings/logic/view-model.ts
@@ -0,0 +1,60 @@
+import { normalizeChatLimit } from "../../../core/chunks";
+
+/**
+ * Pure core for the settings feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The settings feature exposes FE-local, user-tunable settings. Currently the
+ * chat limit (max loaded chunks per conversation; the policy lives in
+ * `core/chunks/trim.ts`): this module is the view-model seam that parses +
+ * validates a typed value into a normalized limit, so the UI can disable submit
+ * and show an error on garbage input. The bound constants + normalization are
+ * OWNED by `core/chunks` (the single source of truth); this module never
+ * redefines them.
+ */
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's localStorage persistence to this shape). ──────────────────────────
+
+/** Outcome of persisting a chat-limit setting. */
+export type ChatLimitSaveResult =
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
+
+// ── chat-limit parse / validate ───────────────────────────────────────────────
+
+/** Result of parsing a typed chat-limit string. */
+export type ChatLimitParse =
+ | { 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
+ * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT] via `normalizeChatLimit`). Empty or
+ * non-numeric input is an ERROR (so the UI can disable submit + message), NOT a
+ * silent default — the default only applies at boot for an unset key, never
+ * 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) };
+}
+
+/**
+ * Whether saving `typed` would change the `current` chat limit. A no-op save
+ * (empty/invalid, or equal after normalization) should be disabled. This is the
+ * 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;
+}
diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte
new file mode 100644
index 0000000..502212f
--- /dev/null
+++ b/src/features/settings/ui/ChatLimitField.svelte
@@ -0,0 +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";
+
+ 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);
+
+ $effect(() => {
+ const incoming = String(chatLimit);
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ 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;
+ }
+ }
+
+ 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}
+</div>
diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts
new file mode 100644
index 0000000..8f8da25
--- /dev/null
+++ b/src/features/settings/ui/ChatLimitField.test.ts
@@ -0,0 +1,89 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { ChatLimitSaveResult } from "../logic/view-model";
+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>;
+} {
+ 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("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");
+
+ 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 } });
+
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "abc");
+
+ 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 },
+ });
+
+ 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();
+ });
+
+ 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" }));
+
+ // 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
new file mode 100644
index 0000000..73b11d6
--- /dev/null
+++ b/src/features/smart-scroll/index.ts
@@ -0,0 +1,25 @@
+export type {
+ ScrollCommand,
+ ScrollGeometry,
+ SmartScrollResult,
+ SmartScrollState,
+} from "./logic/smart-scroll";
+export {
+ 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";
+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",
+} as const;
diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts
new file mode 100644
index 0000000..94ba1d7
--- /dev/null
+++ b/src/features/smart-scroll/logic/smart-scroll.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import {
+ 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,
+};
+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 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 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);
+ });
+});
+
+describe("smart-scroll reducer", () => {
+ 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 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 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("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
new file mode 100644
index 0000000..4a04812
--- /dev/null
+++ b/src/features/smart-scroll/logic/smart-scroll.ts
@@ -0,0 +1,93 @@
+// Pure smart-scroll reducer — "stick the transcript to the bottom while it grows,
+// unless the user has scrolled up". Zero DOM, zero Svelte: it takes scroll
+// GEOMETRY snapshots in and returns the next state plus an optional scroll
+// COMMAND for the shell to execute. The injected shell (the Svelte action) reads
+// the geometry off a real element and runs the commands.
+
+/** 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;
+}
+
+/** Distance (px) from the bottom within which we still consider the view "at bottom". */
+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,
+): boolean {
+ 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;
+}
+
+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;
+}
+
+/** 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;
+}
+
+/** Initial state — start stuck so the first content snaps to the bottom. */
+export function createSmartScrollState(): SmartScrollState {
+ return { stuck: true };
+}
+
+function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult {
+ return { state, command, showButton: !state.stuck };
+}
+
+/**
+ * The user scrolled (or the viewport resized). Re-derive `stuck` purely from
+ * geometry: near the bottom ⇒ stuck (follow), otherwise unstuck. Never emits a
+ * 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);
+}
+
+/**
+ * Content changed (a streamed delta, a new message, history loaded). If we're
+ * stuck, emit a non-animated scroll to keep up; otherwise leave the user where
+ * 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);
+}
+
+/**
+ * The user asked to return to the bottom (clicked the button). Force-stick and
+ * emit an animated scroll.
+ */
+export function onResume(_state: SmartScrollState): SmartScrollResult {
+ return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
+}
+
+/**
+ * The transcript context changed entirely (e.g. a conversation/tab switch).
+ * 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 });
+}
diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte
new file mode 100644
index 0000000..38ec1f5
--- /dev/null
+++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte
@@ -0,0 +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();
+</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}
+>
+ <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
new file mode 100644
index 0000000..fee6561
--- /dev/null
+++ b/src/features/smart-scroll/ui/controller.svelte.ts
@@ -0,0 +1,140 @@
+// Injected shell for smart-scroll: binds a real scrollable element to the pure
+// reducer (logic/smart-scroll). It owns the reactive `showButton` flag (a thin
+// rune wrapper over the reducer state), runs the scroll COMMANDS the reducer
+// emits against the element, and listens at the outermost edges (the element's
+// `scroll`/`scrollend` events + a ResizeObserver on the content). No ambient
+// state: the consumer instantiates ONE controller per scroll region and disposes
+// it on unmount.
+
+import {
+ 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;
+}
+
+function geometryOf(el: HTMLElement): ScrollGeometry {
+ 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));
+ },
+ };
+}
diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts
new file mode 100644
index 0000000..906cc91
--- /dev/null
+++ b/src/features/smart-scroll/ui/controller.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it, vi } from "vitest";
+import { createSmartScrollController } from "./controller.svelte";
+
+// A minimal fake of the only DOM surface the controller touches: scroll
+// 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,
+ };
+}
+
+describe("smart-scroll controller", () => {
+ 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("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);
+ });
+
+ 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);
+
+ 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("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);
+
+ // 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();
+
+ // 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);
+ });
+});
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index afa3127..1e21e3d 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -1,3 +1,9 @@
export { buildInvoke, planSurface } from "./logic/plan";
export type { FieldView, SurfaceRenderPlan } from "./logic/types";
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",
+} as const;
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
new file mode 100644
index 0000000..8d55eb7
--- /dev/null
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -0,0 +1,48 @@
+import type { QueuedMessage } from "@dispatch/wire";
+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,
+});
+
+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 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.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
new file mode 100644
index 0000000..79707a5
--- /dev/null
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -0,0 +1,45 @@
+import type { QueuedMessage } from "@dispatch/wire";
+
+/**
+ * Pure parser for the `rendererId: "message-queue"` custom-field payload.
+ *
+ * The message-queue extension's per-conversation surface emits ONE `custom`
+ * field with `rendererId: "message-queue"` and `payload: QueuePayload`
+ * (`{ messages: QueuedMessage[] }` — the current queue snapshot). This parser
+ * validates the untyped `payload: unknown` at the network seam so a
+ * hostile/partial payload can never crash the renderer (graceful skip → null).
+ *
+ * Empty `messages` is a valid, parseable state (the queue is empty — nothing to
+ * render); the caller hides the panel. Null is returned only for a malformed
+ * payload shape.
+ */
+export interface MessageQueueData {
+ 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)
+ );
+}
+
+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 };
+}
+
+/** The `rendererId` the message-queue extension's `custom` surface field uses. */
+export const MESSAGE_QUEUE_RENDERER_ID = "message-queue";
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
index 50d6f11..9c8a34d 100644
--- a/src/features/surface-host/logic/plan.test.ts
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -1,161 +1,250 @@
import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract";
import { describe, expect, it } from "vitest";
-import { buildInvoke, planSurface } from "./plan";
+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 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) => f.label)).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);
- expect(plan.fields[0]?.label).toBe("Known");
- });
-
- it("drops custom fields (no renderer registered)", () => {
- 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(2);
- expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]);
- });
-
- it("returns empty fields for an empty spec", () => {
- const plan = planSurface(makeSpec());
- expect(plan.fields).toEqual([]);
- });
-
- it("drops all fields when all are custom", () => {
- const plan = planSurface(
- makeSpec(
- { kind: "custom", rendererId: "x", payload: null },
- { kind: "custom", rendererId: "y", payload: 42 },
- ),
- );
- expect(plan.fields).toEqual([]);
- });
+ 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([]);
+ });
});
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 5b4530b..c8f82b9 100644
--- a/src/features/surface-host/logic/plan.ts
+++ b/src/features/surface-host/logic/plan.ts
@@ -1,59 +1,123 @@
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
-import type { FieldView, SurfaceRenderPlan } from "./types";
+import type {
+ FieldView,
+ NumberFieldView,
+ RenderGroup,
+ StatFieldView,
+ SurfaceRenderPlan,
+} from "./types";
-const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]);
+const KNOWN_KINDS = new Set([
+ "toggle",
+ "progress",
+ "selector",
+ "stat",
+ "number",
+ "button",
+ "custom",
+]);
/**
* Validate and normalise a SurfaceSpec into a renderable plan.
- * Keeps known field kinds in order; drops unknown kinds and `custom` fields
- * (no renderer registry yet — graceful skip, never throw).
+ * Keeps known field kinds in order (including `custom`, carried through verbatim
+ * for the renderer to dispatch on `rendererId`); drops unknown kinds — graceful
+ * skip, never throw. Whether a `custom` field actually renders is a RENDER-time
+ * 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 "button":
- fields.push({
- kind: "button",
- label: field.label,
- action: field.action,
- });
- 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 };
+}
+
+/**
+ * Coalesce a field list into render groups: maximal runs of consecutive `stat`
+ * fields become one `stats` group (rendered as a single aligned table), every
+ * 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;
}
/**
@@ -62,13 +126,13 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
* 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
new file mode 100644
index 0000000..6fb558a
--- /dev/null
+++ b/src/features/surface-host/logic/table.test.ts
@@ -0,0 +1,47 @@
+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("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.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
new file mode 100644
index 0000000..5d2b831
--- /dev/null
+++ b/src/features/surface-host/logic/table.ts
@@ -0,0 +1,54 @@
+/**
+ * Pure parser for the `rendererId: "table"` custom-field payload.
+ *
+ * This is the FRONTEND-side renderer contract for tabular custom fields: a
+ * backend that wants a table emits a `custom` field with `rendererId: "table"`
+ * and a payload of `{ columns: string[]; rows: (string|number)[][] }`. Cells are
+ * coerced to strings. Anything that does not match the shape returns `null`, so
+ * the renderer gracefully skips it (never throws on hostile/partial data).
+ */
+
+export interface TableData {
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
+}
+
+function isStringArray(v: unknown): v is unknown[] {
+ 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;
+}
+
+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 };
+}
diff --git a/src/features/surface-host/logic/todo.test.ts b/src/features/surface-host/logic/todo.test.ts
new file mode 100644
index 0000000..66ff036
--- /dev/null
+++ b/src/features/surface-host/logic/todo.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { parseTodoPayload, type TodoItem } from "./todo";
+
+const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({
+ 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 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("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();
+ });
+});
diff --git a/src/features/surface-host/logic/todo.ts b/src/features/surface-host/logic/todo.ts
new file mode 100644
index 0000000..8b8a5ef
--- /dev/null
+++ b/src/features/surface-host/logic/todo.ts
@@ -0,0 +1,49 @@
+/**
+ * Pure parser for the `rendererId: "todo"` custom-field payload.
+ *
+ * The `todo` backend extension maintains a per-conversation task list (written
+ * by the model via a `todo_write` tool) and exposes it as a read-only
+ * conversation-scoped surface with one `custom` field
+ * (`rendererId: "todo"`, `payload: TodoPayload`). This parser validates the
+ * untyped `payload: unknown` at the network seam so a hostile/partial payload
+ * can never crash the renderer (graceful skip → null).
+ *
+ * The `TodoItem` type is NOT in `@dispatch/wire` — it is defined by the `todo`
+ * extension and carried in the surface payload, so we define it here (the FE's
+ * rendering contract for the shape). Empty `todos` is a valid, parseable state
+ * (the model hasn't created a list / cleared it); the caller hides the panel.
+ */
+export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
+
+export interface TodoItem {
+ readonly content: string;
+ readonly status: TodoStatus;
+}
+
+export interface TodoData {
+ 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);
+}
+
+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 };
+}
+
+/** The `rendererId` the `todo` extension's `custom` surface field uses. */
+export const TODO_RENDERER_ID = "todo";
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
index f24438a..11c222f 100644
--- a/src/features/surface-host/logic/types.ts
+++ b/src/features/surface-host/logic/types.ts
@@ -2,51 +2,89 @@ 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;
+}
+
+/**
+ * Normalised view-model for a number field — the free-value counterpart to
+ * selector. `min`/`max`/`step`/`unit` are optional semantic hints (absent when
+ * 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;
}
/** 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;
+}
+
+/**
+ * Normalised view-model for a custom (escape-hatch) field. The plan carries it
+ * through verbatim; the renderer dispatches on `rendererId` (a renderer KIND,
+ * 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;
}
/** A normalised field view-model — one entry per renderable field kind. */
export type FieldView =
- | ToggleFieldView
- | ProgressFieldView
- | SelectorFieldView
- | StatFieldView
- | ButtonFieldView;
+ | 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[];
}
+
+/**
+ * A render group: a maximal run of consecutive `stat` fields (rendered together
+ * as one aligned label/value table), or a single non-stat field. Grouping is a
+ * 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> };
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
new file mode 100644
index 0000000..554fa02
--- /dev/null
+++ b/src/features/surface-host/ui/MessageQueueList.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import { parseMessageQueuePayload } from "../logic/message-queue";
+
+ 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));
+</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>
+{/if}
diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte
new file mode 100644
index 0000000..5a67087
--- /dev/null
+++ b/src/features/surface-host/ui/Number.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+ 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();
+
+ // 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>
+</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/Stat.svelte b/src/features/surface-host/ui/Stat.svelte
deleted file mode 100644
index e184dab..0000000
--- a/src/features/surface-host/ui/Stat.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-<script lang="ts">
- import type { StatFieldView } from "../logic/types";
-
- let { field }: { field: StatFieldView } = $props();
-</script>
-
-<dl>
- <dt>{field.label}</dt>
- <dd>{field.value}</dd>
-</dl>
diff --git a/src/features/surface-host/ui/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte
new file mode 100644
index 0000000..c559352
--- /dev/null
+++ b/src/features/surface-host/ui/StatTable.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ 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();
+</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>
+</div>
diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte
new file mode 100644
index 0000000..e47c122
--- /dev/null
+++ b/src/features/surface-host/ui/SurfaceTable.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import Table from "../../../components/Table.svelte";
+ import { parseTablePayload } from "../logic/table";
+
+ 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));
+</script>
+
+{#if data !== null}
+ <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 4207913..aed8d03 100644
--- a/src/features/surface-host/ui/SurfaceView.svelte
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -1,33 +1,49 @@
<script lang="ts">
- import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { planSurface } from "../logic/plan";
- import Button from "./Button.svelte";
- import Progress from "./Progress.svelte";
- import Selector from "./Selector.svelte";
- import Stat from "./Stat.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));
+ 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 plan.fields as field (field)}
- {#if field.kind === "toggle"}
- <Toggle {field} surfaceId={spec.id} {onInvoke} />
- {:else if field.kind === "progress"}
- <Progress {field} />
- {:else if field.kind === "selector"}
- <Selector {field} surfaceId={spec.id} {onInvoke} />
- {:else if field.kind === "stat"}
- <Stat {field} />
- {:else if field.kind === "button"}
- <Button {field} surfaceId={spec.id} {onInvoke} />
- {/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
new file mode 100644
index 0000000..cffefde
--- /dev/null
+++ b/src/features/surface-host/ui/TodoList.svelte
@@ -0,0 +1,66 @@
+<script lang="ts">
+ import { parseTodoPayload } from "../logic/todo";
+
+ let { payload }: { readonly payload: unknown } = $props();
+
+ const data = $derived(parseTodoPayload(payload));
+</script>
+
+<!-- Fixed at 30% of the viewport height so the region is consistent whether the
+ list is empty or overflowing — it always reserves the space and scrolls
+ internally (mirrors the tabs view). -->
+<ul class="flex h-[30vh] flex-col gap-1 overflow-y-auto pr-1">
+ {#if data !== null && data.todos.length > 0}
+ {#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}
+ {:else}
+ <li class="text-xs opacity-60">No tasks yet.</li>
+ {/if}
+</ul>
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
new file mode 100644
index 0000000..50d9d21
--- /dev/null
+++ b/src/features/system-prompt/index.ts
@@ -0,0 +1,17 @@
+export type {
+ LoadSystemPrompt,
+ LoadSystemPromptVariables,
+ SaveSystemPrompt,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ VariableGroup,
+} from "./logic/view-model";
+export { buildTag, groupVariables, insertTag, isDynamicVariable } 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",
+} as const;
diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts
new file mode 100644
index 0000000..223327b
--- /dev/null
+++ b/src/features/system-prompt/logic/view-model.test.ts
@@ -0,0 +1,90 @@
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ 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("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("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("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"]);
+ });
+
+ 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);
+ });
+
+ 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
new file mode 100644
index 0000000..e202ee3
--- /dev/null
+++ b/src/features/system-prompt/logic/view-model.ts
@@ -0,0 +1,106 @@
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the system prompt builder — zero DOM, zero effects, zero Svelte.
+ *
+ * The system prompt is a global template, stored on the backend, resolved once
+ * per conversation at first turn (then persisted for prompt-cache safety). The
+ * frontend builder exposes the template text plus a palette of available
+ * variables; clicking a variable inserts `[type:name]` at the cursor. This module
+ * holds the pure logic: tag construction, insertion into a template, and
+ * grouping of variables. The HTTP edge is injected via the ports defined below.
+ */
+
+// ── 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 };
+
+export type LoadSystemPrompt = () => Promise<SystemPromptLoadResult>;
+
+export type SystemPromptSaveResult =
+ | { 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 };
+
+export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResult>;
+
+// ── Template helpers ──────────────────────────────────────────────────────────
+
+/** Build the literal placeholder `[type:name]` for a variable. */
+export function buildTag(type: string, name: string): string {
+ 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}]`;
+}
+
+/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */
+export function buildIfNotTag(type: string, name: string): string {
+ return `[if !${type}:${name}]`;
+}
+
+export interface Insertion {
+ /** Template text after insertion. */
+ template: string;
+ /** New cursor position (caret index) after insertion. */
+ cursor: number;
+}
+
+/**
+ * Insert `tag` into `template` at the current selection range. Replaces any
+ * selected text. Returns both the new template and the new cursor position so
+ * the caller can restore the caret after the inserted tag.
+ */
+export function insertTag(
+ 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 };
+}
+
+// ── Variable grouping ─────────────────────────────────────────────────────────
+
+export interface VariableGroup {
+ /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
+ readonly type: string;
+ /** Variables of this type. */
+ readonly variables: readonly SystemPromptVariable[];
+}
+
+/**
+ * Group variables by type, preserving the order of first appearance within each
+ * group and the order of first-appearing types.
+ */
+export function groupVariables(
+ 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) ?? [] }));
+}
+
+/** Whether a variable is "dynamic" (any name is valid, e.g. `file:<path>`). */
+export function isDynamicVariable(variable: SystemPromptVariable): boolean {
+ return variable.dynamic === true;
+}
diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
new file mode 100644
index 0000000..2e92e26
--- /dev/null
+++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
@@ -0,0 +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();
+ });
+</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}
+>
+ <!-- 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 835788a..7520215 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,15 +1,23 @@
export type { Tab, TabsState } from "./tabs";
export {
- activeTab,
- closeTab,
- createTab,
- deriveTitle,
- initialState,
- newDraft,
- selectTab,
- setModel,
- setTitle,
+ 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";
-export { default as TabBar } from "./ui/TabBar.svelte";
+export { default as TabList } from "./ui/TabList.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",
+} as const;
diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts
index cba527e..d044b1f 100644
--- a/src/features/tabs/tabs-store.svelte.ts
+++ b/src/features/tabs/tabs-store.svelte.ts
@@ -1,67 +1,73 @@
import type { Tab, TabsState } from "./tabs";
import {
- initialState,
- closeTab as reduceCloseTab,
- createTab as reduceCreateTab,
- newDraft as reduceNewDraft,
- 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;
- 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));
- },
- 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 81ec8ad..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" }],
- 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" });
- expect(storage.data?.tabs).toHaveLength(1);
- expect(storage.data?.activeConversationId).toBe("c1");
-
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
- 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" });
- expect(store.tabs).toHaveLength(1);
- expect(store.activeConversationId).toBe("c1");
-
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
- 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" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
-
- 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" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
- store.createTab({ conversationId: "c3", model: "m3", title: "T3" });
-
- 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" });
- 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" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
-
- 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" });
+ 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" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2" });
+ 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 3034e76..ec93076 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -1,191 +1,225 @@
import { describe, expect, it } from "vitest";
import type { Tab, TabsState } from "./tabs";
import {
- activeTab,
- closeTab,
- createTab,
- deriveTitle,
- initialState,
- newDraft,
- selectTab,
- setModel,
- setTitle,
+ activeTab,
+ closeTab,
+ createTab,
+ deriveTitle,
+ initialState,
+ MIN_HANDLE_LENGTH,
+ newDraft,
+ selectTab,
+ setModel,
+ setTitle,
+ shortHandle,
} from "./tabs";
const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({
- conversationId,
- model,
- title,
+ 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("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");
+ });
});
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index 9af522f..63cda35 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -1,74 +1,114 @@
export interface Tab {
- readonly conversationId: string;
- readonly model: string;
- readonly title: 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) return persisted;
- 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 };
+}
+
+/**
+ * Add a tab WITHOUT switching the active conversation — used by the
+ * `conversation.open` WS broadcast (CLI `--open`): the tab appears in the
+ * 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 };
}
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 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). */
+export const MIN_HANDLE_LENGTH = 4;
+
+/**
+ * The short "handle" shown on a tab: the shortest prefix of `conversationId`
+ * (at least `MIN_HANDLE_LENGTH` chars) that is unique among all open tabs — a
+ * git-style short id. Grows by a char only when another open tab shares the
+ * prefix, and shrinks back when that sibling closes. Pure: the id + every open
+ * 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;
}
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 53df0be..ae2a021 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,148 +1,298 @@
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
-import TabBar from "./ui/TabBar.svelte";
+import TabList from "./ui/TabList.svelte";
const sampleTabs: readonly Tab[] = [
- { conversationId: "c1", model: "openai/gpt-4", title: "First" },
- { conversationId: "c2", model: "anthropic/claude-3", title: "Second" },
- { conversationId: "c3", model: "google/gemini", title: "Third" },
+ { 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");
- });
+describe("TabList", () => {
+ it("renders one role=tab element per tab showing each title", () => {
+ render(TabList, {
+ 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("marks the active tab as aria-selected", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c2",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs[0]).toHaveAttribute("aria-selected", "false");
+ expect(tabs[1]).toHaveAttribute("aria-selected", "true");
+ expect(tabs[2]).toHaveAttribute("aria-selected", "false");
+ });
+
+ 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(TabList, {
+ 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(TabList, {
+ 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(TabList, {
+ 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("shows visible 'New Chat' text when activeConversationId is null", () => {
+ render(TabList, {
+ 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(TabList, {
+ 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(TabList, {
+ 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 each tab as a single vertical row (flex-col list, not a horizontal strip)", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ // The scroll region containing the tab rows is a vertical flex column.
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs.length).toBeGreaterThan(0);
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("flex-col");
+ });
+
+ it("fixes the tab list region at 40vh so a long set scrolls internally", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const region = tabs[0]?.parentElement;
+ expect(region).toHaveClass("h-[40vh]");
+ expect(region).toHaveClass("overflow-y-auto");
+ });
+
+ it("calls onRename when a tab title is double-clicked and committed with Enter", async () => {
+ const onRename = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onRename,
+ },
+ });
+
+ const titleButtons = screen.getAllByRole("button");
+ // The inline-rename trigger is the title span (role=button) — find the one
+ // whose text matches the first tab's title.
+ const titleButton = titleButtons.find((b) => b.textContent === "First");
+ if (!titleButton) throw new Error("title button not found");
+ await user.dblClick(titleButton);
+
+ const input = screen.getByRole("textbox");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(onRename).toHaveBeenCalledTimes(1);
+ expect(onRename).toHaveBeenCalledWith("c1", "Renamed");
+ });
+
+ it("copies the conversation id to the clipboard and highlights the badge text when clicked", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ });
+
+ const onSelect = vi.fn();
+
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const idBadge = screen.getByRole("button", { name: "Copy conversation id c1" });
+ await fireEvent.click(idBadge);
+
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText).toHaveBeenCalledWith("c1");
+ // The badge text is NOT swapped (no width shift) — the highlight (selection)
+ // is the only indicator.
+ expect(idBadge).toHaveTextContent("c1");
+ expect(idBadge).not.toHaveTextContent("Copied");
+ // The badge text is selected (highlighted) as the copy indicator.
+ const selection = window.getSelection();
+ expect(selection?.toString()).toBe("c1");
+ // Clicking the ID must NOT switch tabs (the badge stops propagation).
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("shows a loading RING for a 'queued' tab and loading DOTS for an 'active' tab", () => {
+ render(TabList, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ statusFor: (id: string) => (id === "c1" ? "queued" : id === "c2" ? "active" : undefined),
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ // c1 is queued → a ring (DaisyUI `loading-ring`), labeled "Queued".
+ const queuedRing = screen.getByLabelText("Queued");
+ expect(queuedRing.className).toContain("loading-ring");
+ expect(queuedRing.closest('[role="tab"]')).toHaveTextContent("First");
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(3);
+ const activeTab = tabs[1];
+ const idleTab = tabs[2];
+ if (activeTab === undefined || idleTab === undefined) throw new Error("missing tabs");
+
+ // c2 is active → loading dots (NOT a ring).
+ expect(activeTab.querySelector(".loading-dots")).not.toBeNull();
+ expect(activeTab.querySelector(".loading-ring")).toBeNull();
+
+ // c3 has no status → no spinner at all.
+ expect(idleTab.querySelector(".loading")).toBeNull();
+ });
});
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
deleted file mode 100644
index 76fab05..0000000
--- a/src/features/tabs/ui/TabBar.svelte
+++ /dev/null
@@ -1,54 +0,0 @@
-<script lang="ts">
- import type { Tab } from "../tabs";
-
- let {
- tabs,
- activeConversationId,
- onSelect,
- onClose,
- onNewDraft,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- } = $props();
-</script>
-
-<div class="overflow-x-auto border-b border-base-300">
- <div class="tabs tabs-border min-w-max">
- {#each tabs as tab (tab.conversationId)}
- <div
- class="tab"
- 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="max-w-[120px] truncate">{tab.title}</span>
- <button
- class="btn btn-ghost btn-xs ml-1"
- 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)]"
- class:tab-active={activeConversationId === null}
- aria-label="New chat"
- onclick={() => onNewDraft()}
- >
- +
- </button>
- </div>
-</div>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
new file mode 100644
index 0000000..6d85065
--- /dev/null
+++ b/src/features/tabs/ui/TabList.svelte
@@ -0,0 +1,183 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+ import { 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();
+
+ // 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;
+ });
+
+ // 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 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;
+ }
+
+ // Click-to-copy the agent (conversation) id: clicking the ID badge copies the
+ // FULL conversationId to the clipboard (the stable, useful id — the badge
+ // only shows a short prefix) and highlights the badge text as feedback. The
+ // highlight (text selection) is the indicator — no text is swapped, so the
+ // badge keeps a stable width. If the clipboard write fails, the selection is
+ // already in place so the user can Ctrl+C the text manually.
+ async function copyId(conversationId: string, el: HTMLElement): Promise<void> {
+ selectText(el);
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+ try {
+ await clipboard.writeText(conversationId);
+ } catch {
+ // Selection already lets the user copy manually.
+ }
+ }
+
+ function selectText(el: HTMLElement): void {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a
+ long tab set scrolls inside this region instead of growing the whole sidebar. -->
+ <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1">
+ {#each tabs as tab (tab.conversationId)}
+ <div
+ class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300"
+ class:bg-base-300={tab.conversationId === activeConversationId}
+ role="tab"
+ tabindex="0"
+ aria-selected={tab.conversationId === activeConversationId}
+ title={tab.title}
+ onclick={() => onSelect(tab.conversationId)}
+ onkeydown={(e) => {
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ }}
+ >
+ <button
+ type="button"
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content"
+ data-copy-id={tab.conversationId}
+ title="Click to copy conversation id"
+ aria-label={`Copy conversation id ${tab.conversationId}`}
+ onclick={(e) => {
+ e.stopPropagation();
+ void copyId(tab.conversationId, e.currentTarget);
+ }}
+ >
+ {handles.get(tab.conversationId) ?? tab.conversationId}
+ </button>
+ {#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) === "queued"}
+ <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). -->
+ <span
+ class="loading loading-ring loading-xs shrink-0 text-primary"
+ aria-label="Queued"
+ title="Waiting for a concurrency slot"
+ ></span>
+ {:else if statusFor?.(tab.conversationId) === "active"}
+ <span class="loading loading-dots 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}
+ </div>
+
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm w-full border border-base-300"
+ class:btn-primary={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ {#if activeConversationId === null}
+ New Chat
+ {:else}
+ + New chat
+ {/if}
+ </button>
+</div>
diff --git a/src/features/views/index.ts b/src/features/views/index.ts
new file mode 100644
index 0000000..164241d
--- /dev/null
+++ b/src/features/views/index.ts
@@ -0,0 +1,15 @@
+export {
+ 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)",
+} as const;
diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts
new file mode 100644
index 0000000..aadbf5e
--- /dev/null
+++ b/src/features/views/logic/panels.test.ts
@@ -0,0 +1,55 @@
+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 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 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("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);
+ });
+});
diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts
new file mode 100644
index 0000000..fa22be6
--- /dev/null
+++ b/src/features/views/logic/panels.ts
@@ -0,0 +1,49 @@
+/**
+ * Pure reducer for the view sidebar's panel stack.
+ *
+ * A "view" is the RESERVED Dispatch sidebar affordance (see GLOSSARY): the user
+ * stacks panels, each showing one view KIND chosen from a dropdown, and adds
+ * more with a `+` button. This module is the pure model — zero DOM, zero Svelte.
+ * The component (`ViewSidebar.svelte`) is a thin runes wrapper over it.
+ *
+ * `id` is a per-session stable key for `{#each}` only; it is never persisted.
+ */
+
+export interface ViewPanel {
+ 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;
+}
+
+/**
+ * Seed state. Each entry becomes one panel in order; pass `["surfaces"]` to open
+ * 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 };
+}
+
+export function addPanel(state: PanelsState, kind: string | null = null): PanelsState {
+ 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) };
+}
+
+export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState {
+ 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
new file mode 100644
index 0000000..e9e8682
--- /dev/null
+++ b/src/features/views/ui/ViewSidebar.svelte
@@ -0,0 +1,95 @@
+<script lang="ts">
+ import { type Snippet, untrack } from "svelte";
+ import {
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ } from "../logic/panels";
+
+ 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();
+
+ // 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));
+ }
+</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>
+
+ {#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>
+</div>
diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts
new file mode 100644
index 0000000..0618506
--- /dev/null
+++ b/src/features/views/ui/ViewSidebar.test.ts
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { createRawSnippet } from "svelte";
+import { describe, expect, it } from "vitest";
+import ViewSidebar from "./ViewSidebar.svelte";
+
+const kinds = [
+ { 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>`,
+}));
+
+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("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("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");
+ });
+});
diff --git a/src/features/vision/index.ts b/src/features/vision/index.ts
new file mode 100644
index 0000000..6956d75
--- /dev/null
+++ b/src/features/vision/index.ts
@@ -0,0 +1,30 @@
+export type {
+ CompactionModelOption,
+ ImageLimitParse,
+ LoadVisionSettings,
+ LoadVisionSettingsResult,
+ SaveVisionSettings,
+ SaveVisionSettingsResult,
+ VisionSettings,
+ VisionSettingsPatch,
+} from "./logic/view-model";
+export {
+ AUTO_COMPACTION_MODEL,
+ compactionModelChanged,
+ compactionModelFromValue,
+ compactionModelOptions,
+ DEFAULT_IMAGE_LIMIT,
+ imageLimitChanged,
+ imageLimitLabel,
+ MAX_IMAGE_LIMIT,
+ normalizeVisionSettings,
+ parseImageLimit,
+ selectedCompactionValue,
+} from "./logic/view-model";
+export { default as VisionSettingsView } from "./ui/VisionSettingsView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "vision",
+ description: "Global vision settings (image compaction limit + model)",
+} as const;
diff --git a/src/features/vision/logic/view-model.test.ts b/src/features/vision/logic/view-model.test.ts
new file mode 100644
index 0000000..b1db822
--- /dev/null
+++ b/src/features/vision/logic/view-model.test.ts
@@ -0,0 +1,198 @@
+import type { ModelMetadata } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ AUTO_COMPACTION_MODEL,
+ compactionModelChanged,
+ compactionModelFromValue,
+ compactionModelOptions,
+ DEFAULT_IMAGE_LIMIT,
+ imageLimitChanged,
+ imageLimitLabel,
+ MAX_IMAGE_LIMIT,
+ normalizeVisionSettings,
+ parseImageLimit,
+ selectedCompactionValue,
+} from "./view-model";
+
+describe("normalizeVisionSettings", () => {
+ it("returns the value as-is for a well-formed body", () => {
+ expect(normalizeVisionSettings({ imageLimit: 5, compactionModel: "kimi/k2" })).toEqual({
+ imageLimit: 5,
+ compactionModel: "kimi/k2",
+ });
+ });
+
+ it("coerces a null compactionModel to null", () => {
+ expect(normalizeVisionSettings({ imageLimit: 10, compactionModel: null })).toEqual({
+ imageLimit: 10,
+ compactionModel: null,
+ });
+ });
+
+ it("defaults imageLimit when absent or non-numeric", () => {
+ expect(normalizeVisionSettings({ compactionModel: "kimi/k2" })).toEqual({
+ imageLimit: DEFAULT_IMAGE_LIMIT,
+ compactionModel: "kimi/k2",
+ });
+ expect(normalizeVisionSettings({ imageLimit: "oops", compactionModel: null })).toEqual({
+ imageLimit: DEFAULT_IMAGE_LIMIT,
+ compactionModel: null,
+ });
+ });
+
+ it("floors a fractional imageLimit", () => {
+ expect(normalizeVisionSettings({ imageLimit: 7.9, compactionModel: null }).imageLimit).toBe(7);
+ });
+
+ it("defaults for a null/non-object body (never crashes)", () => {
+ expect(normalizeVisionSettings(null)).toEqual({
+ imageLimit: DEFAULT_IMAGE_LIMIT,
+ compactionModel: null,
+ });
+ expect(normalizeVisionSettings("nope")).toEqual({
+ imageLimit: DEFAULT_IMAGE_LIMIT,
+ compactionModel: null,
+ });
+ });
+
+ it("treats a negative imageLimit as the default", () => {
+ expect(normalizeVisionSettings({ imageLimit: -3, compactionModel: null }).imageLimit).toBe(
+ DEFAULT_IMAGE_LIMIT,
+ );
+ });
+
+ it("treats an empty compactionModel string as null", () => {
+ expect(
+ normalizeVisionSettings({ imageLimit: 10, compactionModel: "" }).compactionModel,
+ ).toBeNull();
+ });
+});
+
+describe("parseImageLimit", () => {
+ it("parses a non-negative integer", () => {
+ expect(parseImageLimit("5")).toEqual({ ok: true, value: 5 });
+ expect(parseImageLimit("0")).toEqual({ ok: true, value: 0 });
+ });
+
+ it("floors a fractional value", () => {
+ expect(parseImageLimit("7.9")).toEqual({ ok: true, value: 7 });
+ });
+
+ it("trims whitespace", () => {
+ expect(parseImageLimit(" 12 ")).toEqual({ ok: true, value: 12 });
+ });
+
+ it("errors on empty input", () => {
+ expect(parseImageLimit("")).toEqual({ ok: false, error: "Enter a number." });
+ });
+
+ it("errors on non-numeric input", () => {
+ expect(parseImageLimit("lots").ok).toBe(false);
+ });
+
+ it("errors on a negative value", () => {
+ expect(parseImageLimit("-1").ok).toBe(false);
+ });
+
+ it("errors when above the max", () => {
+ expect(parseImageLimit(String(MAX_IMAGE_LIMIT + 1)).ok).toBe(false);
+ });
+
+ it("accepts the max", () => {
+ expect(parseImageLimit(String(MAX_IMAGE_LIMIT))).toEqual({ ok: true, value: MAX_IMAGE_LIMIT });
+ });
+});
+
+describe("imageLimitChanged", () => {
+ it("is true when the typed value differs after normalization", () => {
+ expect(imageLimitChanged("5", 10)).toBe(true);
+ });
+
+ it("is false when equal", () => {
+ expect(imageLimitChanged("10", 10)).toBe(false);
+ });
+
+ it("is false for invalid input", () => {
+ expect(imageLimitChanged("abc", 10)).toBe(false);
+ expect(imageLimitChanged("", 10)).toBe(false);
+ });
+
+ it("compares after flooring", () => {
+ expect(imageLimitChanged("7.9", 7)).toBe(false);
+ });
+});
+
+describe("compactionModelOptions", () => {
+ const modelInfo: Record<string, ModelMetadata> = {
+ "kimi/k2": { vision: true },
+ "kimi/k1.5": { vision: true },
+ "umans/glm-5.2": { vision: false },
+ "openai/gpt-4": { contextWindow: 128000 },
+ };
+
+ it("includes the Auto option first", () => {
+ const opts = compactionModelOptions([], {});
+ expect(opts).toHaveLength(1);
+ expect(opts[0]?.auto).toBe(true);
+ expect(opts[0]?.value).toBe(AUTO_COMPACTION_MODEL);
+ });
+
+ it("includes only vision-capable models, in catalog order", () => {
+ const models = ["umans/glm-5.2", "kimi/k2", "openai/gpt-4", "kimi/k1.5"];
+ const opts = compactionModelOptions(models, modelInfo);
+ expect(opts.map((o) => o.label)).toEqual(["Auto (server-selected)", "kimi/k2", "kimi/k1.5"]);
+ });
+
+ it("excludes non-vision models even with metadata present", () => {
+ const opts = compactionModelOptions(["umans/glm-5.2"], modelInfo);
+ expect(opts).toHaveLength(1); // only Auto
+ });
+});
+
+describe("compactionModel value round-trip", () => {
+ it("selectedCompactionValue maps null to the auto sentinel", () => {
+ expect(selectedCompactionValue(null)).toBe(AUTO_COMPACTION_MODEL);
+ });
+
+ it("selectedCompactionValue maps a model name to itself", () => {
+ expect(selectedCompactionValue("kimi/k2")).toBe("kimi/k2");
+ });
+
+ it("compactionModelFromValue maps the auto sentinel back to null", () => {
+ expect(compactionModelFromValue(AUTO_COMPACTION_MODEL)).toBeNull();
+ });
+
+ it("compactionModelFromValue maps a model name to itself", () => {
+ expect(compactionModelFromValue("kimi/k2")).toBe("kimi/k2");
+ });
+});
+
+describe("compactionModelChanged", () => {
+ it("is true when the value maps to a different model", () => {
+ expect(compactionModelChanged("kimi/k2", null)).toBe(true);
+ expect(compactionModelChanged(AUTO_COMPACTION_MODEL, "kimi/k2")).toBe(true);
+ });
+
+ it("is false when equal (null vs auto, or same model)", () => {
+ expect(compactionModelChanged(AUTO_COMPACTION_MODEL, null)).toBe(false);
+ expect(compactionModelChanged("kimi/k2", "kimi/k2")).toBe(false);
+ });
+});
+
+describe("imageLimitLabel", () => {
+ it("labels null as loading", () => {
+ expect(imageLimitLabel(null)).toBe("Loading…");
+ });
+
+ it("labels 0 as disabled", () => {
+ expect(imageLimitLabel(0)).toBe("0 (compaction disabled)");
+ });
+
+ it("labels the default with (default)", () => {
+ expect(imageLimitLabel(DEFAULT_IMAGE_LIMIT)).toBe(`${DEFAULT_IMAGE_LIMIT} (default)`);
+ });
+
+ it("labels other values plainly", () => {
+ expect(imageLimitLabel(7)).toBe("7");
+ });
+});
diff --git a/src/features/vision/logic/view-model.ts b/src/features/vision/logic/view-model.ts
new file mode 100644
index 0000000..97a7093
--- /dev/null
+++ b/src/features/vision/logic/view-model.ts
@@ -0,0 +1,189 @@
+import type { ModelMetadata } from "@dispatch/transport-contract";
+import { isVisionModel } from "../../chat";
+
+/**
+ * Pure core for the vision settings feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The global vision configuration (`GET`/`PUT /settings/vision`) controls image
+ * compaction: how many native images a vision model keeps per turn before the
+ * oldest are transcribed to text (`imageLimit`), and which vision model does the
+ * transcribing (`compactionModel`, null = auto). This module is the view-model
+ * seam: typed settings, parse/validate, dirty-check, network normalization, and
+ * the vision-capable model option list. The composition root adapts the store's
+ * HTTP calls to the injected ports.
+ */
+
+// ── Types (owned locally — consumer-defines-port; the contract shapes are a
+// plain REST surface not in a shared contract package version bump, mirroring
+// the heartbeat/mcp pattern). If the backend promotes these to a shared
+// package, swap the local types for the imports + re-mirror. ──────────────
+
+/** The global vision settings (mirrors `VisionSettingsResponse`). */
+export interface VisionSettings {
+ /** Max native images per turn (default 10); 0 disables compaction. */
+ readonly imageLimit: number;
+ /** Which model transcribes old images (`<key>/<model>`), or null = auto. */
+ readonly compactionModel: string | null;
+}
+
+/** A partial update (mirrors `SetVisionSettingsRequest`). */
+export interface VisionSettingsPatch {
+ readonly imageLimit?: number;
+ readonly compactionModel?: string | null;
+}
+
+// ── Injected ports (consumer-defines-port). ───────────────────────────────────
+
+/** Outcome of loading the vision settings. */
+export type LoadVisionSettingsResult =
+ | { readonly ok: true; readonly settings: VisionSettings }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of saving a partial vision-settings update. */
+export type SaveVisionSettingsResult =
+ | { readonly ok: true; readonly settings: VisionSettings }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadVisionSettings = () => Promise<LoadVisionSettingsResult>;
+export type SaveVisionSettings = (patch: VisionSettingsPatch) => Promise<SaveVisionSettingsResult>;
+
+// ── Constants ────────────────────────────────────────────────────────────────
+
+/** The backend's default image limit when none is persisted. */
+export const DEFAULT_IMAGE_LIMIT = 10;
+/** Upper bound for the image limit input (defensive — the backend owns real clamping). */
+export const MAX_IMAGE_LIMIT = 1000;
+
+// ── Network normalization (pure; coerces untyped JSON at the seam). ──────────
+
+/**
+ * Coerce an untyped JSON body (from `GET /settings/vision`) into a valid
+ * `VisionSettings`. A malformed/partial response can never crash the renderer:
+ * `imageLimit` falls back to the default; `compactionModel` coerces to null.
+ */
+export function normalizeVisionSettings(body: unknown): VisionSettings {
+ if (body === null || typeof body !== "object") {
+ return { imageLimit: DEFAULT_IMAGE_LIMIT, compactionModel: null };
+ }
+ const raw = body as Record<string, unknown>;
+ const limitRaw = raw.imageLimit;
+ const imageLimit =
+ typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw >= 0
+ ? Math.floor(limitRaw)
+ : DEFAULT_IMAGE_LIMIT;
+ const modelRaw = raw.compactionModel;
+ const compactionModel = typeof modelRaw === "string" && modelRaw.length > 0 ? modelRaw : null;
+ return { imageLimit, compactionModel };
+}
+
+// ── imageLimit parse / validate ───────────────────────────────────────────────
+
+/** Result of parsing a typed image-limit string. */
+export type ImageLimitParse =
+ | { readonly ok: true; readonly value: number }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Parse a typed image-limit string into a non-negative integer (floored,
+ * clamped to [0, MAX_IMAGE_LIMIT]). Empty or non-numeric input is an ERROR
+ * (so the UI can disable submit + message), NOT a silent default.
+ */
+export function parseImageLimit(raw: string): ImageLimitParse {
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) {
+ return { ok: false, error: "Enter a number." };
+ }
+ const n = Number(trimmed);
+ if (!Number.isFinite(n) || n < 0) {
+ return { ok: false, error: "Must be 0 or a positive number." };
+ }
+ const floored = Math.floor(n);
+ if (floored > MAX_IMAGE_LIMIT) {
+ return { ok: false, error: `Must be at most ${MAX_IMAGE_LIMIT}.` };
+ }
+ return { ok: true, value: floored };
+}
+
+/**
+ * Whether saving `typed` would change the `current` image limit. A no-op save
+ * (empty/invalid, or equal) should be disabled. This is the dirty-check for the
+ * input — it must NOT mutate or clamp, only compare.
+ */
+export function imageLimitChanged(typed: string, current: number): boolean {
+ const parsed = parseImageLimit(typed);
+ if (!parsed.ok) return false;
+ return parsed.value !== current;
+}
+
+// ── compactionModel option list ───────────────────────────────────────────────
+
+/**
+ * The sentinel value for the "Auto" option (null compactionModel — the server
+ * auto-selects a vision model). Used as the `<option value>` for the auto row.
+ */
+export const AUTO_COMPACTION_MODEL = "__auto__";
+
+/** A selectable compaction-model option. */
+export interface CompactionModelOption {
+ /** The value to send (`<key>/<model>`, or `AUTO_COMPACTION_MODEL` for auto). */
+ readonly value: string;
+ /** The human-readable label. */
+ readonly label: string;
+ /** Whether this is the "Auto" sentinel. */
+ readonly auto: boolean;
+}
+
+/**
+ * Build the compaction-model dropdown options: the "Auto" entry (null) plus
+ * every vision-capable model from the catalog (those with
+ * `modelInfo[name].vision === true`), in catalog order. Non-vision models are
+ * excluded — they cannot transcribe images.
+ */
+export function compactionModelOptions(
+ models: readonly string[],
+ modelInfo: Readonly<Record<string, ModelMetadata>>,
+): CompactionModelOption[] {
+ const options: CompactionModelOption[] = [
+ { value: AUTO_COMPACTION_MODEL, label: "Auto (server-selected)", auto: true },
+ ];
+ for (const name of models) {
+ if (isVisionModel(modelInfo, name)) {
+ options.push({ value: name, label: name, auto: false });
+ }
+ }
+ return options;
+}
+
+/**
+ * The `<option value>` to mark selected for the current `compactionModel`:
+ * `AUTO_COMPACTION_MODEL` when null (auto), else the model name itself.
+ */
+export function selectedCompactionValue(compactionModel: string | null): string {
+ return compactionModel ?? AUTO_COMPACTION_MODEL;
+}
+
+/**
+ * Convert a selected `<option value>` back into a `VisionSettingsPatch`
+ * `compactionModel` (the auto sentinel → null). Returns the patch alone so the
+ * caller can merge it with other fields.
+ */
+export function compactionModelFromValue(value: string): string | null {
+ return value === AUTO_COMPACTION_MODEL ? null : value;
+}
+
+/**
+ * Whether choosing `value` would change the current `compactionModel`.
+ */
+export function compactionModelChanged(value: string, current: string | null): boolean {
+ return compactionModelFromValue(value) !== current;
+}
+
+// ── Labels ────────────────────────────────────────────────────────────────────
+
+/** A human-readable label for the current image limit (for the status line). */
+export function imageLimitLabel(imageLimit: number | null): string {
+ if (imageLimit === null) return "Loading…";
+ if (imageLimit === 0) return "0 (compaction disabled)";
+ if (imageLimit === DEFAULT_IMAGE_LIMIT) return `${imageLimit} (default)`;
+ return String(imageLimit);
+}
diff --git a/src/features/vision/ui/VisionSettingsView.svelte b/src/features/vision/ui/VisionSettingsView.svelte
new file mode 100644
index 0000000..2b4ebba
--- /dev/null
+++ b/src/features/vision/ui/VisionSettingsView.svelte
@@ -0,0 +1,192 @@
+<script lang="ts">
+ import type { ModelMetadata } from "@dispatch/transport-contract";
+ import {
+ compactionModelChanged,
+ compactionModelFromValue,
+ compactionModelOptions,
+ DEFAULT_IMAGE_LIMIT,
+ imageLimitChanged,
+ imageLimitLabel,
+ parseImageLimit,
+ selectedCompactionValue,
+ type LoadVisionSettings,
+ type SaveVisionSettings,
+ type VisionSettings,
+ } from "../logic/view-model";
+
+ let {
+ models,
+ modelInfo = {},
+ load,
+ save,
+ }: {
+ /** The model catalog (`GET /models` `models`) — for the compaction-model dropdown. */
+ models: readonly string[];
+ /** Per-model metadata — to filter the dropdown to vision-capable models. */
+ modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ /** Load the global vision settings (`GET /settings/vision`). */
+ load: LoadVisionSettings;
+ /** Save a partial vision-settings update (`PUT /settings/vision`). */
+ save: SaveVisionSettings;
+ } = $props();
+
+ let settings = $state<VisionSettings | null>(null);
+ let loadError = $state<string | null>(null);
+
+ // imageLimit input state.
+ let imageLimitInput = $state("");
+ let savingImageLimit = $state(false);
+ let imageLimitError = $state<string | null>(null);
+ let imageLimitSaved = $state(false);
+
+ // compactionModel select state.
+ let compactionSaving = $state(false);
+ let compactionError = $state<string | null>(null);
+ let compactionSaved = $state(false);
+
+ // Load on mount.
+ $effect(() => {
+ void refresh();
+ });
+
+ async function refresh(): Promise<void> {
+ const result = await load();
+ if (result.ok) {
+ settings = result.settings;
+ imageLimitInput = String(result.settings.imageLimit);
+ loadError = null;
+ imageLimitError = null;
+ imageLimitSaved = false;
+ compactionError = null;
+ compactionSaved = false;
+ } else {
+ loadError = result.error;
+ }
+ }
+
+ const options = $derived(compactionModelOptions(models, modelInfo));
+ const selectedCompaction = $derived(
+ settings ? selectedCompactionValue(settings.compactionModel) : selectedCompactionValue(null),
+ );
+ const limitLabel = $derived(imageLimitLabel(settings?.imageLimit ?? null));
+
+ const canSaveImageLimit = $derived(
+ settings !== null && imageLimitChanged(imageLimitInput, settings.imageLimit),
+ );
+
+ async function handleSaveImageLimit(): Promise<void> {
+ if (settings === null || savingImageLimit) return;
+ const parsed = parseImageLimit(imageLimitInput);
+ if (!parsed.ok) {
+ imageLimitError = parsed.error;
+ imageLimitSaved = false;
+ return;
+ }
+ savingImageLimit = true;
+ imageLimitError = null;
+ imageLimitSaved = false;
+ const result = await save({ imageLimit: parsed.value });
+ savingImageLimit = false;
+ if (result.ok) {
+ settings = result.settings;
+ imageLimitInput = String(result.settings.imageLimit);
+ imageLimitSaved = true;
+ } else {
+ imageLimitError = result.error;
+ }
+ }
+
+ async function handleCompactionChange(e: Event): Promise<void> {
+ if (settings === null || compactionSaving) return;
+ const value = (e.currentTarget as HTMLSelectElement).value;
+ if (!compactionModelChanged(value, settings.compactionModel)) return;
+ compactionSaving = true;
+ compactionError = null;
+ compactionSaved = false;
+ const result = await save({ compactionModel: compactionModelFromValue(value) });
+ compactionSaving = false;
+ if (result.ok) {
+ settings = result.settings;
+ compactionSaved = true;
+ } else {
+ compactionError = result.error;
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ {#if loadError}
+ <p class="text-xs text-error">{loadError}</p>
+ {/if}
+
+ {#if settings === null && !loadError}
+ <p class="text-xs opacity-60">Loading vision settings…</p>
+ {:else if settings !== null}
+ <!-- imageLimit -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Image limit</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-sm w-24"
+ placeholder={String(DEFAULT_IMAGE_LIMIT)}
+ bind:value={imageLimitInput}
+ disabled={savingImageLimit}
+ aria-label="Image limit (max native images per turn)"
+ />
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canSaveImageLimit || savingImageLimit}
+ onclick={handleSaveImageLimit}
+ >
+ {#if savingImageLimit}
+ <span class="loading loading-spinner loading-xs"></span>
+ Saving…
+ {:else}
+ Save
+ {/if}
+ </button>
+ </div>
+ <p class="text-xs opacity-50">
+ Current: {limitLabel}
+ <br />
+ Max native images per turn before the oldest are transcribed to text.
+ 0 disables compaction. Default is {DEFAULT_IMAGE_LIMIT}.
+ </p>
+ {#if imageLimitError}
+ <p class="text-xs text-error">{imageLimitError}</p>
+ {:else if imageLimitSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
+
+ <!-- compactionModel -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Compaction model</span>
+ <select
+ class="select select-bordered select-sm w-full"
+ value={selectedCompaction}
+ disabled={compactionSaving}
+ onchange={handleCompactionChange}
+ aria-label="Compaction model (which vision model transcribes old images)"
+ >
+ {#each options as opt (opt.value)}
+ <option value={opt.value}>{opt.label}</option>
+ {/each}
+ </select>
+ {#if compactionSaving}
+ <p class="text-xs opacity-60">Saving…</p>
+ {/if}
+ <p class="text-xs opacity-50">
+ The vision-capable model that transcribes old images to text. "Auto" lets the server choose.
+ </p>
+ {#if compactionError}
+ <p class="text-xs text-error">{compactionError}</p>
+ {:else if compactionSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
+ {/if}
+</div>
diff --git a/src/features/vision/ui/VisionSettingsView.test.ts b/src/features/vision/ui/VisionSettingsView.test.ts
new file mode 100644
index 0000000..48afc71
--- /dev/null
+++ b/src/features/vision/ui/VisionSettingsView.test.ts
@@ -0,0 +1,241 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type {
+ LoadVisionSettingsResult,
+ SaveVisionSettingsResult,
+ VisionSettings,
+} from "../logic/view-model";
+import VisionSettingsView from "./VisionSettingsView.svelte";
+
+const SETTINGS: VisionSettings = { imageLimit: 10, compactionModel: null };
+
+function fakeLoad(settings: VisionSettings = SETTINGS): {
+ calls: number;
+ impl: () => Promise<LoadVisionSettingsResult>;
+} {
+ let calls = 0;
+ return {
+ get calls() {
+ return calls;
+ },
+ impl: async () => {
+ calls += 1;
+ return { ok: true, settings };
+ },
+ };
+}
+
+function fakeSaveOk(): {
+ patches: object[];
+ impl: (patch: object) => Promise<SaveVisionSettingsResult>;
+} {
+ const patches: object[] = [];
+ return {
+ get patches() {
+ return patches;
+ },
+ impl: async (patch) => {
+ patches.push(patch);
+ // Merge into the current settings to simulate the server echo.
+ const next: VisionSettings = {
+ imageLimit:
+ "imageLimit" in patch ? (patch as VisionSettings).imageLimit : SETTINGS.imageLimit,
+ compactionModel:
+ "compactionModel" in patch
+ ? (patch as VisionSettings).compactionModel
+ : SETTINGS.compactionModel,
+ };
+ return { ok: true, settings: next };
+ },
+ };
+}
+
+describe("VisionSettingsView", () => {
+ it("loads settings on mount and seeds the imageLimit input", async () => {
+ const load = fakeLoad({ imageLimit: 7, compactionModel: "kimi/k2" });
+ render(VisionSettingsView, {
+ props: {
+ models: ["kimi/k2"],
+ modelInfo: { "kimi/k2": { vision: true } },
+ load: load.impl,
+ save: fakeSaveOk().impl,
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Image limit/)).toHaveValue("7");
+ });
+ // "Auto" is selected (compactionModel was kimi/k2 here actually)
+ expect(screen.getByLabelText(/Compaction model/)).toHaveValue("kimi/k2");
+ });
+
+ it("disables Save until the imageLimit input differs", async () => {
+ const load = fakeLoad();
+ const save = fakeSaveOk();
+ const user = userEvent.setup();
+ render(VisionSettingsView, {
+ props: { models: [], modelInfo: {}, load: load.impl, save: save.impl },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+
+ const input = screen.getByLabelText(/Image limit/);
+ await user.clear(input);
+ await user.type(input, "5");
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ });
+
+ it("saves the imageLimit on click and confirms", async () => {
+ const load = fakeLoad();
+ const save = fakeSaveOk();
+ const user = userEvent.setup();
+ render(VisionSettingsView, {
+ props: { models: [], modelInfo: {}, load: load.impl, save: save.impl },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Image limit/)).toHaveValue("10");
+ });
+
+ const input = screen.getByLabelText(/Image limit/);
+ await user.clear(input);
+ await user.type(input, "3");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await vi.waitFor(() => {
+ expect(save.patches).toEqual([{ imageLimit: 3 }]);
+ });
+ expect(screen.getByText(/Saved/i)).toBeInTheDocument();
+ });
+
+ it("shows an error for a non-numeric imageLimit on save", async () => {
+ const load = fakeLoad();
+ const save = fakeSaveOk();
+ const user = userEvent.setup();
+ render(VisionSettingsView, {
+ props: { models: [], modelInfo: {}, load: load.impl, save: save.impl },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Image limit/)).toHaveValue("10");
+ });
+
+ const input = screen.getByLabelText(/Image limit/);
+ await user.clear(input);
+ await user.type(input, "abc");
+ // Save is disabled for invalid input, so no save fires; the error surfaces
+ // only on a submit attempt — but the button is disabled, so just assert that.
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ expect(save.patches).toEqual([]);
+ });
+
+ it("renders the compaction-model dropdown with Auto + vision-capable models", async () => {
+ const load = fakeLoad();
+ render(VisionSettingsView, {
+ props: {
+ models: ["kimi/k2", "umans/glm-5.2", "kimi/k1.5"],
+ modelInfo: {
+ "kimi/k2": { vision: true },
+ "kimi/k1.5": { vision: true },
+ "umans/glm-5.2": { vision: false },
+ },
+ load: load.impl,
+ save: fakeSaveOk().impl,
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Compaction model/)).toBeInTheDocument();
+ });
+ const select = screen.getByLabelText(/Compaction model/) as HTMLSelectElement;
+ const optionTexts = Array.from(select.options).map((o) => o.textContent ?? "");
+ expect(optionTexts).toEqual(["Auto (server-selected)", "kimi/k2", "kimi/k1.5"]);
+ // Non-vision glm-5.2 is excluded.
+ expect(optionTexts.some((t) => t.includes("glm-5.2"))).toBe(false);
+ });
+
+ it("saves the compactionModel on change (Auto → a vision model)", async () => {
+ const load = fakeLoad({ imageLimit: 10, compactionModel: null });
+ const save = fakeSaveOk();
+ const user = userEvent.setup();
+ render(VisionSettingsView, {
+ props: {
+ models: ["kimi/k2"],
+ modelInfo: { "kimi/k2": { vision: true } },
+ load: load.impl,
+ save: save.impl,
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Compaction model/)).toBeInTheDocument();
+ });
+
+ await user.selectOptions(screen.getByLabelText(/Compaction model/), "kimi/k2");
+
+ await vi.waitFor(() => {
+ expect(save.patches).toEqual([{ compactionModel: "kimi/k2" }]);
+ });
+ expect(screen.getByText(/Saved/i)).toBeInTheDocument();
+ });
+
+ it("saves null (Auto) when the auto option is chosen", async () => {
+ const load = fakeLoad({ imageLimit: 10, compactionModel: "kimi/k2" });
+ const save = fakeSaveOk();
+ const user = userEvent.setup();
+ render(VisionSettingsView, {
+ props: {
+ models: ["kimi/k2"],
+ modelInfo: { "kimi/k2": { vision: true } },
+ load: load.impl,
+ save: save.impl,
+ },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Compaction model/)).toHaveValue("kimi/k2");
+ });
+
+ await user.selectOptions(screen.getByLabelText(/Compaction model/), "__auto__");
+
+ await vi.waitFor(() => {
+ expect(save.patches).toEqual([{ compactionModel: null }]);
+ });
+ });
+
+ it("surfaces a load error", async () => {
+ const load = vi.fn(async () => ({ ok: false, error: "vision unavailable" }) as const);
+ render(VisionSettingsView, {
+ props: { models: [], modelInfo: {}, load, save: fakeSaveOk().impl },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("vision unavailable")).toBeInTheDocument();
+ });
+ });
+
+ it("surfaces a save error", async () => {
+ const load = fakeLoad();
+ const save = vi.fn(async () => ({ ok: false, error: "boom" }) as const);
+ const user = userEvent.setup();
+ render(VisionSettingsView, {
+ props: { models: [], modelInfo: {}, load: load.impl, save },
+ });
+
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText(/Image limit/)).toHaveValue("10");
+ });
+
+ const input = screen.getByLabelText(/Image limit/);
+ await user.clear(input);
+ await user.type(input, "3");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
new file mode 100644
index 0000000..18d8939
--- /dev/null
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -0,0 +1,189 @@
+import { describe, expect, it, vi } from "vitest";
+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;
+}
+
+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 [] 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("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("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("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("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("star PUTs /star with no body and returns the updated workspace", async () => {
+ const ws = {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: true,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.star("a");
+ 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/star`);
+ expect(call?.[1]).toEqual({ method: "PUT" });
+ });
+
+ it("unstar DELETEs /star with no body and returns the updated workspace", async () => {
+ const ws = {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.unstar("a");
+ 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/star`);
+ expect(call?.[1]).toEqual({ method: "DELETE" });
+ });
+
+ it("star surfaces a 400 for an invalid slug", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 400, body: { error: "invalid slug" } }]),
+ );
+ const result = await http.star("UPPER");
+ expect(result).toEqual({ ok: false, error: "invalid slug" });
+ });
+
+ it("unstar surfaces the backend error on failure", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 500, body: { error: "Failed to unstar workspace" } }]),
+ );
+ const result = await http.unstar("a");
+ expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" });
+ });
+});
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
new file mode 100644
index 0000000..5673881
--- /dev/null
+++ b/src/features/workspaces/adapter/http.ts
@@ -0,0 +1,187 @@
+import type {
+ DeleteWorkspaceResponse,
+ EnsureWorkspaceRequest,
+ SetWorkspaceDefaultComputerRequest,
+ SetWorkspaceDefaultCwdRequest,
+ SetWorkspaceTitleRequest,
+ Workspace,
+ WorkspaceEntry,
+ WorkspaceListResponse,
+ WorkspaceResponse,
+} from "@dispatch/transport-contract";
+
+/**
+ * Workspace HTTP effects — the injected edge that talks to the backend's
+ * workspace endpoints. Mirrors the store's fetch pattern: `httpBase` + an
+ * injected `fetchImpl` (so it is testable without the network). Returns typed
+ * `WorkspaceResult<T>` (`{ok,value}` | `{ok:false,error}`) for mutating ops so a
+ * caller can surface the backend's `{ error }` reason; reads return data or a
+ * safe empty/null on failure (non-fatal — the UI falls back gracefully).
+ *
+ * Endpoints ([email protected]):
+ * - `GET /workspaces` → list
+ * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure
+ * - `GET /workspaces/:id` (404 → null) → get
+ * - `PUT /workspaces/:id/title` → rename
+ * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd
+ * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2)
+ * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority)
+ * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar
+ * - `DELETE /workspaces/:id` (409 for "default") → delete
+ */
+export type WorkspaceResult<T> =
+ | { 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>>;
+ /** Star a workspace (concurrency priority). Create-on-miss; no body. */
+ star(id: string): Promise<WorkspaceResult<Workspace>>;
+ /** Unstar a workspace. Create-on-miss; no body. */
+ unstar(id: string): 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}`;
+ }
+}
+
+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 [];
+ }
+ },
+
+ 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 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 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 star(id): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/star`, {
+ method: "PUT",
+ });
+ 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 : "Star failed" };
+ }
+ },
+
+ async unstar(id): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/star`, {
+ method: "DELETE",
+ });
+ 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 : "Unstar 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
new file mode 100644
index 0000000..dab1dec
--- /dev/null
+++ b/src/features/workspaces/index.ts
@@ -0,0 +1,21 @@
+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,
+} from "./logic/route";
+export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model";
+export type { WorkspaceStore } from "./store.svelte";
+export { createWorkspaceStore } from "./store.svelte";
+export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte";
+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",
+} as const;
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts
new file mode 100644
index 0000000..96e0ff3
--- /dev/null
+++ b/src/features/workspaces/logic/route.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import {
+ 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("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("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("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 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 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);
+ });
+});
+
+describe("workspacePath", () => {
+ 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
new file mode 100644
index 0000000..015c6d6
--- /dev/null
+++ b/src/features/workspaces/logic/route.ts
@@ -0,0 +1,63 @@
+/**
+ * Pure routing logic for the workspaces feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The app is URL-driven: the root path `/` is the workspaces HOME (lists all
+ * workspaces); a single path segment `/<id>` opens the workspace with that id
+ * (the slug). This module holds the pure mapping from a pathname to a `Route`,
+ * plus the slug-validation rules mirrored from the backend's `PUT /workspaces/:id`.
+ */
+
+/**
+ * The workspace slug regex (mirrors the backend's `PUT /workspaces/:id`
+ * validation): 1–40 chars, lowercase alphanumeric with internal hyphens only
+ * (no leading/trailing hyphen). `"default"` matches and is a valid (but
+ * non-deletable) id.
+ */
+export const WORKSPACE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
+
+/** A route derived from the URL path. */
+export type Route = { readonly kind: "home" } | { readonly kind: "workspace"; readonly id: string };
+
+/** The reserved id of the always-present fallback workspace. */
+export const DEFAULT_WORKSPACE_ID = "default";
+
+/**
+ * Parse a pathname into a `Route`. `/` (or empty) → home; a leading segment →
+ * the workspace with that id (URL-decoded, surrounding slashes trimmed). Deeper
+ * paths take their FIRST segment (nested routes are not used in v1). Pure:
+ * pathname in, route out. Does NOT validate the slug — an invalid id still
+ * produces a `workspace` route; the backend's ensure call rejects it (the FE
+ * 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 };
+}
+
+/**
+ * Whether a slug is valid for a NEW workspace (the form the backend accepts).
+ * Used by the home view's "new workspace" input before navigating.
+ */
+export function isValidSlug(slug: string): boolean {
+ return WORKSPACE_SLUG_RE.test(slug);
+}
+
+/**
+ * Build the URL path for a workspace id. Used when navigating to / linking a
+ * workspace. Pure: id in, path string out.
+ */
+export function workspacePath(id: string): string {
+ return `/${id}`;
+}
+
+function safeDecode(segment: string): string {
+ 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
new file mode 100644
index 0000000..44d2f31
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -0,0 +1,179 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model";
+
+describe("relativeTime", () => {
+ 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 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 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}$/);
+ });
+});
+
+describe("pageTitle", () => {
+ // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
+ const ws = (id: string, title: string): WorkspaceEntry => ({
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ 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: {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("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");
+ });
+});
+
+describe("sortWorkspaces", () => {
+ const entry = (id: string, starred: boolean, lastActivityAt: number): WorkspaceEntry => ({
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred,
+ createdAt: 0,
+ lastActivityAt,
+ conversationCount: 0,
+ });
+
+ it("puts starred workspaces before unstarred", () => {
+ const list = [entry("plain", false, 9_000), entry("star", true, 1_000)];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "plain"]);
+ });
+
+ it("within the starred group, sorts by lastActivityAt desc", () => {
+ const list = [
+ entry("old-star", true, 1_000),
+ entry("new-star", true, 5_000),
+ entry("plain", false, 9_000),
+ ];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["new-star", "old-star", "plain"]);
+ });
+
+ it("within the unstarred group, sorts by lastActivityAt desc", () => {
+ const list = [
+ entry("star", true, 1_000),
+ entry("old-plain", false, 1_000),
+ entry("new-plain", false, 5_000),
+ ];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "new-plain", "old-plain"]);
+ });
+
+ it("returns a new array (does not mutate the input)", () => {
+ const list = [entry("plain", false, 9_000), entry("star", true, 1_000)];
+ const sorted = sortWorkspaces(list);
+ expect(sorted).not.toBe(list);
+ // Input order is preserved (not mutated).
+ expect(list.map((w) => w.id)).toEqual(["plain", "star"]);
+ expect(sorted.map((w) => w.id)).toEqual(["star", "plain"]);
+ });
+
+ it("handles an empty list", () => {
+ expect(sortWorkspaces([])).toEqual([]);
+ });
+
+ it("is stable for equal lastActivityAt within a group", () => {
+ const list = [
+ entry("first", false, 5_000),
+ entry("second", false, 5_000),
+ entry("third", false, 5_000),
+ ];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["first", "second", "third"]);
+ });
+});
+
+describe("applyStarred", () => {
+ const entry = (id: string, starred: boolean): WorkspaceEntry => ({
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+
+ it("sets the named workspace's starred flag", () => {
+ const list = [entry("a", false), entry("b", false)];
+ const next = applyStarred(list, "b", true);
+ expect(next.map((w) => [w.id, w.starred])).toEqual([
+ ["a", false],
+ ["b", true],
+ ]);
+ });
+
+ it("returns a new array (does not mutate the input)", () => {
+ const list = [entry("a", false)];
+ const next = applyStarred(list, "a", true);
+ expect(next).not.toBe(list);
+ expect(list[0]?.starred).toBe(false);
+ expect(next[0]?.starred).toBe(true);
+ });
+
+ it("leaves other entries referentially unchanged (only the target is replaced)", () => {
+ const a = entry("a", false);
+ const b = entry("b", false);
+ const next = applyStarred([a, b], "b", true);
+ expect(next[0]).toBe(a);
+ expect(next[1]).not.toBe(b);
+ });
+
+ it("leaves the list unchanged when the id is absent (not yet loaded)", () => {
+ const list = [entry("a", false)];
+ const next = applyStarred(list, "missing", true);
+ expect(next.map((w) => [w.id, w.starred])).toEqual([["a", false]]);
+ });
+
+ it("can revert by re-applying the previous value", () => {
+ const list = [entry("a", false)];
+ const optimistic = applyStarred(list, "a", true);
+ expect(optimistic[0]?.starred).toBe(true);
+ const reverted = applyStarred(optimistic, "a", false);
+ expect(reverted[0]?.starred).toBe(false);
+ });
+});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
new file mode 100644
index 0000000..b994b7c
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.ts
@@ -0,0 +1,71 @@
+/**
+ * Pure view-model helpers for the workspaces feature — zero DOM, zero effects.
+ */
+
+import type { WorkspaceEntry } from "@dispatch/wire";
+import type { Route } from "./route";
+
+/**
+ * The browser tab / page (`document.title`) text for a route. The home route
+ * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}",
+ * using the workspace's display title and falling back to the URL slug (`id`)
+ * until the workspace list has loaded it — the backend defaults a workspace's
+ * title to its id, so the slug is the correct transient value. Pure: 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}`;
+}
+
+/**
+ * Sort workspaces for display: starred first, then most-recently-active. Pure:
+ * the list in, a NEW sorted array out (the input is not mutated). Starred
+ * workspaces jump to the top (the FE-side echo of their concurrency-priority);
+ * within each group (starred / not) `lastActivityAt` desc breaks ties, matching
+ * the backend's list ordering. Stable for equal `lastActivityAt`.
+ */
+export function sortWorkspaces<T extends WorkspaceEntry>(workspaces: readonly T[]): T[] {
+ return [...workspaces].sort((a, b) => {
+ if (a.starred !== b.starred) return a.starred ? -1 : 1;
+ return b.lastActivityAt - a.lastActivityAt;
+ });
+}
+
+/**
+ * Return a NEW list with the one workspace's `starred` flag set (immutably —
+ * the entry is replaced, the rest keep their identity). Pure: the optimistic
+ * star/unstar transformation shared by the apply + the error revert. A missing
+ * `id` (not yet in the list — e.g. starring a workspace the home view hasn't
+ * loaded) leaves the list unchanged; the backend's create-on-miss still applies
+ * server-side and a subsequent refresh reconciles.
+ */
+export function applyStarred<T extends WorkspaceEntry>(
+ workspaces: readonly T[],
+ id: string,
+ starred: boolean,
+): T[] {
+ return workspaces.map((w) => (w.id === id ? { ...w, starred } : w));
+}
+
+/**
+ * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h",
+ * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps
+ * (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}`;
+}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
new file mode 100644
index 0000000..22d73b5
--- /dev/null
+++ b/src/features/workspaces/store.svelte.ts
@@ -0,0 +1,114 @@
+import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract";
+import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
+import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
+import { applyStarred, sortWorkspaces } from "./logic/view-model";
+
+/**
+ * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the
+ * list + loading/error state; mutations call the injected `WorkspaceHttp` and
+ * refresh the list. State is per-instance (no ambient store); subscriptions are
+ * owned by the composition root.
+ */
+export interface WorkspaceStore {
+ /**
+ * All workspaces, sorted for display: starred first (their FE-side echo of
+ * concurrency-priority), then most-recently-active. The backing list is the
+ * backend's `lastActivityAt`-desc order; this getter re-sorts reactively.
+ */
+ 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>>;
+ /**
+ * Toggle a workspace's star (concurrency priority). Optimistic: the local
+ * `starred` flag flips immediately and the sorted list re-orders; on error it
+ * reverts to the prior value. No full refresh on success (avoids flicker).
+ */
+ setStarred(id: string, starred: boolean): 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[]>([]);
+ // Sorted view (starred first, then most-recent) — derived so it recomputes
+ // only when the backing list changes, not on every read.
+ let sorted = $derived(sortWorkspaces(list));
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+
+ return {
+ get list(): readonly WorkspaceEntry[] {
+ return sorted;
+ },
+ 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 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 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 setStarred(id, starred): Promise<WorkspaceResult<Workspace>> {
+ const prev = list.find((w) => w.id === id)?.starred ?? false;
+ // Optimistic: flip immediately (the sorted getter re-orders reactively).
+ list = applyStarred(list, id, starred);
+ const result = starred ? await http.star(id) : await http.unstar(id);
+ if (!result.ok) {
+ // Revert the optimistic flip on failure.
+ list = applyStarred(list, id, prev);
+ }
+ 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/store.test.ts b/src/features/workspaces/store.test.ts
new file mode 100644
index 0000000..4caac9f
--- /dev/null
+++ b/src/features/workspaces/store.test.ts
@@ -0,0 +1,145 @@
+import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceResult } from "./adapter/http";
+import { createWorkspaceStore } from "./store.svelte";
+
+function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 0,
+ ...overrides,
+ };
+}
+
+/** A fake `WorkspaceHttp` with stubbed star/unstar + a controllable list. */
+function fakeHttp(opts: {
+ list?: readonly WorkspaceEntry[];
+ star?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+ unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+}) {
+ return {
+ list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []),
+ ensure: vi.fn(),
+ get: vi.fn(),
+ setTitle: vi.fn(),
+ setDefaultCwd: vi.fn(),
+ setDefaultComputer: vi.fn(),
+ star:
+ opts.star ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id, starred: true }),
+ }),
+ ),
+ unstar:
+ opts.unstar ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id, starred: false }),
+ }),
+ ),
+ delete: vi.fn(),
+ };
+}
+
+describe("createWorkspaceStore — setStarred", () => {
+ it("optimistically flips starred to true before the request resolves", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", starred: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ let observedDuringCall = false;
+ http.star = vi.fn(async (_id: string): Promise<WorkspaceResult<Workspace>> => {
+ // While the request is in flight, the store already shows the new state.
+ observedDuringCall = store.list[0]?.starred === true;
+ return { ok: true, value: entry({ id: "a", starred: true }) };
+ });
+
+ await store.setStarred("a", true);
+
+ expect(observedDuringCall).toBe(true);
+ expect(http.star).toHaveBeenCalledWith("a");
+ expect(store.list[0]?.starred).toBe(true);
+ });
+
+ it("calls unstar (DELETE) when starring false", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", starred: true })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ await store.setStarred("a", false);
+
+ expect(http.unstar).toHaveBeenCalledWith("a");
+ expect(http.star).not.toHaveBeenCalled();
+ expect(store.list[0]?.starred).toBe(false);
+ });
+
+ it("reverts the optimistic flip on error", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", starred: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ http.star = vi.fn(
+ async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }),
+ );
+
+ const result = await store.setStarred("a", true);
+
+ expect(result).toEqual({ ok: false, error: "boom" });
+ // Reverted to the prior value.
+ expect(store.list[0]?.starred).toBe(false);
+ });
+
+ it("does not set the store-wide load error on a star failure", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", starred: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ http.star = vi.fn(
+ async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }),
+ );
+ await store.setStarred("a", true);
+
+ expect(store.error).toBeNull();
+ });
+
+ it("re-sorts so starred workspaces bubble to the top", async () => {
+ const http = fakeHttp({
+ list: [
+ entry({ id: "plain", starred: false, lastActivityAt: 9_000 }),
+ entry({ id: "star", starred: false, lastActivityAt: 1_000 }),
+ ],
+ });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ // Before: backend order (most-active first).
+ expect(store.list.map((w) => w.id)).toEqual(["plain", "star"]);
+
+ await store.setStarred("star", true);
+
+ // After: starred jumps above the more-recent unstarred workspace.
+ expect(store.list.map((w) => w.id)).toEqual(["star", "plain"]);
+ });
+
+ it("treats a missing id as not-starred and still calls through (create-on-miss)", async () => {
+ const http = fakeHttp({ list: [] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ const result = await store.setStarred("ghost", true);
+
+ expect(result.ok).toBe(true);
+ expect(http.star).toHaveBeenCalledWith("ghost");
+ // The list is unchanged (the workspace wasn't loaded); a refresh reconciles.
+ expect(store.list).toHaveLength(0);
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
new file mode 100644
index 0000000..6de4109
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -0,0 +1,279 @@
+<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,
+ hasActive,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
+ /**
+ * Optional port: returns whether the workspace has at least one active
+ * (generating / queued) conversation — drives a loading-dots indicator on
+ * the card. Wired by the composition root to the app store's
+ * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests).
+ */
+ hasActive?: (workspaceId: string) => boolean;
+ } = $props();
+
+ // Whether at least one conversation in this workspace is currently active
+ // (generating). Reactive: the composition-root port reads the app store's
+ // reactive tab set + lifecycle statuses, so this re-derives on change.
+ const active = $derived(hasActive?.(ws.id) ?? false);
+
+ // ── 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;
+ }
+
+ // ── Star (concurrency priority) ──────────────────────────────────────────────
+ let savingStar = $state(false);
+ let starError = $state<string | null>(null);
+
+ async function toggleStar(): Promise<void> {
+ if (savingStar) return;
+ savingStar = true;
+ starError = null;
+ try {
+ // Optimistic: the store flips `starred` immediately and re-sorts; revert
+ // on error is handled there. We read `ws.starred` for the target value.
+ const result = await store.setStarred(ws.id, !ws.starred);
+ if (!result.ok) starError = result.error;
+ } catch (err) {
+ // A throw (e.g. a rejected effect) — surface it; the store already
+ // reverted the optimistic flip if it got far enough to apply it.
+ starError = err instanceof Error ? err.message : "Star toggle failed";
+ } finally {
+ savingStar = false;
+ }
+ }
+
+ // ── 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}
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0 text-primary"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span
+ >
+ {/if}
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs px-1"
+ disabled={savingStar}
+ aria-pressed={ws.starred}
+ aria-label={ws.starred ? "Unstar workspace" : "Star workspace"}
+ title={ws.starred
+ ? "Starred — its agents get concurrency priority. Click to unstar."
+ : "Star this workspace to give its agents concurrency priority."}
+ onclick={toggleStar}
+ >
+ {#if savingStar}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else if ws.starred}
+ <span class="text-warning" aria-hidden="true">★</span>
+ {:else}
+ <span class="opacity-40" aria-hidden="true">☆</span>
+ {/if}
+ </button>
+ <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}
+
+ {#if starError}
+ <p class="text-xs text-error">{starError}</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)}
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate(workspacePath(ws.id));
+ }}
+ >
+ 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
new file mode 100644
index 0000000..72f4e60
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -0,0 +1,305 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceResult } from "../adapter/http";
+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,
+ starred: false,
+ 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 }),
+ }),
+ ),
+ setStarred: vi.fn(
+ async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, starred }),
+ }),
+ ),
+ 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: 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 navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const onNavigate = vi.fn();
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
+
+ const open = screen.getByRole("link", { name: "Open" });
+ // Still a real link (progressive enhancement): href points at the workspace.
+ expect(open).toHaveAttribute("href", "/my-ws");
+ // But it no longer opens a new browser tab.
+ expect(open).not.toHaveAttribute("target", "_blank");
+ // Clicking navigates in-place via the SPA callback.
+ await user.click(open);
+ expect(onNavigate).toHaveBeenCalledTimes(1);
+ expect(onNavigate).toHaveBeenCalledWith("/my-ws");
+ });
+
+ // ── Active indicator (loading dots) ──────────────────────────────────────
+
+ it("shows no loading-dots when no hasActive port is given", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+ expect(container.querySelector(".loading-dots")).toBeNull();
+ });
+
+ it("shows no loading-dots when hasActive returns false", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: () => false,
+ },
+ });
+ expect(container.querySelector(".loading-dots")).toBeNull();
+ });
+
+ it("shows loading-dots when hasActive returns true", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const { container } = render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: () => true,
+ },
+ });
+ const dots = container.querySelector(".loading-dots");
+ expect(dots).not.toBeNull();
+ // Accessible label ties the indicator to the workspace-active concept.
+ expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations");
+ });
+
+ it("forwards the workspace id to hasActive", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const seen: string[] = [];
+ render(WorkspaceCard, {
+ props: {
+ ws: fakeEntry({ id: "proj-x" }),
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ hasActive: (id: string) => {
+ seen.push(id);
+ return false;
+ },
+ },
+ });
+ expect(seen).toEqual(["proj-x"]);
+ });
+
+ it("renders an outline star button for an unstarred workspace", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+ const star = screen.getByRole("button", { name: "Star workspace" });
+ expect(star).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("renders a filled star button for a starred workspace", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
+ });
+ const star = screen.getByRole("button", { name: "Unstar workspace" });
+ expect(star).toHaveAttribute("aria-pressed", "true");
+ });
+
+ it("toggles the star via the store on click", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Star workspace" }));
+ expect(store.setStarred).toHaveBeenCalledWith("my-ws", true);
+ });
+
+ it("clicking a starred workspace's star calls setStarred(id, false)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Unstar workspace" }));
+ expect(store.setStarred).toHaveBeenCalledWith("my-ws", false);
+ });
+
+ it("renders no star error on a successful toggle", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Star workspace" }));
+ expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument();
+ });
+
+ it("shows an inline error when setStarred fails (result.ok false)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ // The store reverts the optimistic flip on failure, so the entry's
+ // `starred` stays false — fake the revert by returning ok:false unchanged.
+ store.setStarred = vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
+ );
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Star workspace" }));
+
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("re-enables the star button after a failure (savingStar resets)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ store.setStarred = vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
+ );
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const star = screen.getByRole("button", { name: "Star workspace" });
+ await user.click(star);
+ // After the failed toggle, the button is NOT disabled (savingStar reset).
+ expect(star).not.toBeDisabled();
+ });
+
+ it("re-enables the star button even when setStarred throws", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => {
+ throw new Error("network");
+ });
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const star = screen.getByRole("button", { name: "Star workspace" });
+ await user.click(star);
+ // savingStar must reset via try/finally even on a throw.
+ expect(star).not.toBeDisabled();
+ expect(screen.getByText("network")).toBeInTheDocument();
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
new file mode 100644
index 0000000..d97eab7
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -0,0 +1,110 @@
+<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";
+
+ let {
+ store,
+ onNavigate,
+ computers,
+ hasActive,
+ }: {
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ computers: readonly ComputerEntry[];
+ /**
+ * Optional port forwarded to each {@link WorkspaceCard}: whether the
+ * workspace has at least one active (generating / queued) conversation.
+ * Wired by the composition root to the app store. Absent → no indicator.
+ */
+ hasActive?: (workspaceId: string) => boolean;
+ } = $props();
+
+ onMount(() => {
+ void store.refresh();
+ });
+
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
+
+ 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));
+ }
+</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>
+
+ <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}
+ {...(hasActive ? { hasActive } : {})}
+ />
+ {/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/src/themes/monokai.css b/src/themes/monokai.css
new file mode 100644
index 0000000..1597700
--- /dev/null
+++ b/src/themes/monokai.css
@@ -0,0 +1,40 @@
+/*!
+ * Author: Dachi — https://github.com/dachinat
+ * License: CC BY 4.0 — https://creativecommons.org/licenses/by/4.0/
+ * Repo: https://github.com/dachinat/daisyui-themes
+ */
+
+@plugin "daisyui/theme" {
+ name: "monokai";
+ default: false;
+ prefersdark: false;
+ color-scheme: "dark";
+ --color-base-100: #2e2e2e;
+ --color-base-200: #292825;
+ --color-base-300: #3b3a3a;
+ --color-base-content: #d6d6d6;
+ --color-primary: #6c99bb;
+ --color-primary-content: #000000;
+ --color-secondary: #9e86c8;
+ --color-secondary-content: #000000;
+ --color-accent: #b05279;
+ --color-accent-content: #000000;
+ --color-neutral: #e87d3e;
+ --color-neutral-content: #000000;
+ --color-info: #268bd2;
+ --color-info-content: oklch(29% 0.066 243.157);
+ --color-success: #b4d273;
+ --color-success-content: #000000;
+ --color-warning: #e5b567;
+ --color-warning-content: #000000;
+ --color-error: #dc322f;
+ --color-error-content: #000000;
+ --radius-selector: 0.25rem;
+ --radius-field: 0.5rem;
+ --radius-box: 0.25rem;
+ --size-selector: 0.25rem;
+ --size-field: 0.25rem;
+ --border: 1px;
+ --depth: 1;
+ --noise: 0;
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 4078e74..2a2b515 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1,2 +1,5 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+
+/** 5-char git short hash, baked at build time (vite.config.ts `define`). */
+declare const __APP_VERSION__: string;
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 8ca4beb..3229445 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,3 +1,4 @@
+import { execSync } from "node:child_process";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { svelteTesting } from "@testing-library/svelte/vite";
@@ -6,18 +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 },
- 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";
+ }
+}
diff --git a/vitest-setup.ts b/vitest-setup.ts
index 2c29eea..143ac96 100644
--- a/vitest-setup.ts
+++ b/vitest-setup.ts
@@ -1,2 +1,16 @@
import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto";
+
+// jsdom implements neither Element.scrollTo nor ResizeObserver; the smart-scroll
+// 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 = () => {};
+}
+if (typeof globalThis.ResizeObserver === "undefined") {
+ globalThis.ResizeObserver = class {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+ };
+}