summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-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
212 files changed, 28830 insertions, 1286 deletions
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"]);
+ });
+ });
+});