summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features')
-rw-r--r--src/features/cache-warming/index.ts4
-rw-r--r--src/features/cache-warming/logic/view-model.test.ts406
-rw-r--r--src/features/cache-warming/logic/view-model.ts244
-rw-r--r--src/features/cache-warming/ui/CacheWarmingView.svelte468
-rw-r--r--src/features/chat/index.ts28
-rw-r--r--src/features/chat/model-select.test.ts88
-rw-r--r--src/features/chat/model-select.ts44
-rw-r--r--src/features/chat/ports.ts24
-rw-r--r--src/features/chat/reasoning-effort.test.ts68
-rw-r--r--src/features/chat/reasoning-effort.ts32
-rw-r--r--src/features/chat/store.svelte.ts724
-rw-r--r--src/features/chat/store.test.ts3060
-rw-r--r--src/features/chat/test-helpers.ts228
-rw-r--r--src/features/chat/ui.test.ts1526
-rw-r--r--src/features/chat/ui/ChatView.svelte611
-rw-r--r--src/features/chat/ui/CompactionView.svelte278
-rw-r--r--src/features/chat/ui/Composer.svelte358
-rw-r--r--src/features/chat/ui/ModelSelector.svelte82
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte130
-rw-r--r--src/features/computer/index.ts42
-rw-r--r--src/features/computer/logic/view-model.test.ts266
-rw-r--r--src/features/computer/logic/view-model.ts170
-rw-r--r--src/features/computer/ui/ComputerField.svelte410
-rw-r--r--src/features/computer/ui/ComputerSelect.svelte60
-rw-r--r--src/features/conversation-cache/cache.test.ts326
-rw-r--r--src/features/conversation-cache/cache.ts98
-rw-r--r--src/features/conversation-cache/index.ts10
-rw-r--r--src/features/conversation-cache/logic.test.ts248
-rw-r--r--src/features/conversation-cache/logic.ts82
-rw-r--r--src/features/conversation-cache/types.ts38
-rw-r--r--src/features/cwd-lsp/index.ts12
-rw-r--r--src/features/cwd-lsp/logic/view-model.test.ts154
-rw-r--r--src/features/cwd-lsp/logic/view-model.ts140
-rw-r--r--src/features/cwd-lsp/ui/CwdField.svelte170
-rw-r--r--src/features/cwd-lsp/ui/LspStatusView.svelte234
-rw-r--r--src/features/markdown/index.ts4
-rw-r--r--src/features/markdown/logic/markdown.test.ts106
-rw-r--r--src/features/markdown/logic/markdown.ts174
-rw-r--r--src/features/markdown/ui/Markdown.svelte106
-rw-r--r--src/features/markdown/ui/markdown.test.ts52
-rw-r--r--src/features/mcp/index.ts4
-rw-r--r--src/features/mcp/logic/view-model.test.ts136
-rw-r--r--src/features/mcp/logic/view-model.ts128
-rw-r--r--src/features/mcp/ui/McpStatusView.svelte242
-rw-r--r--src/features/settings/index.ts4
-rw-r--r--src/features/settings/logic/view-model.test.ts104
-rw-r--r--src/features/settings/logic/view-model.ts32
-rw-r--r--src/features/settings/ui/ChatLimitField.svelte186
-rw-r--r--src/features/settings/ui/ChatLimitField.test.ts128
-rw-r--r--src/features/smart-scroll/index.ts28
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.test.ts156
-rw-r--r--src/features/smart-scroll/logic/smart-scroll.ts56
-rw-r--r--src/features/smart-scroll/ui/ScrollToBottom.svelte60
-rw-r--r--src/features/smart-scroll/ui/controller.svelte.ts242
-rw-r--r--src/features/smart-scroll/ui/controller.test.ts296
-rw-r--r--src/features/surface-host/index.ts4
-rw-r--r--src/features/surface-host/logic/message-queue.test.ts72
-rw-r--r--src/features/surface-host/logic/message-queue.ts38
-rw-r--r--src/features/surface-host/logic/plan.test.ts468
-rw-r--r--src/features/surface-host/logic/plan.ts210
-rw-r--r--src/features/surface-host/logic/table.test.ts78
-rw-r--r--src/features/surface-host/logic/table.ts66
-rw-r--r--src/features/surface-host/logic/todo.test.ts110
-rw-r--r--src/features/surface-host/logic/todo.ts32
-rw-r--r--src/features/surface-host/logic/types.ts78
-rw-r--r--src/features/surface-host/ui/Button.svelte30
-rw-r--r--src/features/surface-host/ui/MessageQueueList.svelte30
-rw-r--r--src/features/surface-host/ui/Number.svelte72
-rw-r--r--src/features/surface-host/ui/Progress.svelte12
-rw-r--r--src/features/surface-host/ui/Selector.svelte50
-rw-r--r--src/features/surface-host/ui/StatTable.svelte30
-rw-r--r--src/features/surface-host/ui/SurfaceTable.svelte14
-rw-r--r--src/features/surface-host/ui/SurfaceView.svelte87
-rw-r--r--src/features/surface-host/ui/TodoList.svelte106
-rw-r--r--src/features/surface-host/ui/Toggle.svelte36
-rw-r--r--src/features/system-prompt/index.ts18
-rw-r--r--src/features/system-prompt/logic/view-model.test.ts148
-rw-r--r--src/features/system-prompt/logic/view-model.ts74
-rw-r--r--src/features/system-prompt/ui/SystemPromptBuilder.svelte467
-rw-r--r--src/features/tabs/index.ts26
-rw-r--r--src/features/tabs/tabs-store.svelte.ts118
-rw-r--r--src/features/tabs/tabs-store.test.ts282
-rw-r--r--src/features/tabs/tabs.test.ts418
-rw-r--r--src/features/tabs/tabs.ts120
-rw-r--r--src/features/tabs/ui.test.ts404
-rw-r--r--src/features/tabs/ui/TabBar.svelte325
-rw-r--r--src/features/views/index.ts16
-rw-r--r--src/features/views/logic/panels.test.ts88
-rw-r--r--src/features/views/logic/panels.ts34
-rw-r--r--src/features/views/ui/ViewSidebar.svelte170
-rw-r--r--src/features/views/ui/ViewSidebar.test.ts76
-rw-r--r--src/features/workspaces/adapter/http.test.ts224
-rw-r--r--src/features/workspaces/adapter/http.ts242
-rw-r--r--src/features/workspaces/index.ts14
-rw-r--r--src/features/workspaces/logic/route.test.ts112
-rw-r--r--src/features/workspaces/logic/route.ts26
-rw-r--r--src/features/workspaces/logic/view-model.test.ts98
-rw-r--r--src/features/workspaces/logic/view-model.ts32
-rw-r--r--src/features/workspaces/store.svelte.ts132
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.svelte407
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts234
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte165
102 files changed, 9631 insertions, 9569 deletions
diff --git a/src/features/cache-warming/index.ts b/src/features/cache-warming/index.ts
index c432de6..976844b 100644
--- a/src/features/cache-warming/index.ts
+++ b/src/features/cache-warming/index.ts
@@ -3,6 +3,6 @@ export { default as CacheWarmingView } from "./ui/CacheWarmingView.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "cache-warming",
- description: "Prompt-cache warming controls, history, and countdown",
+ name: "cache-warming",
+ description: "Prompt-cache warming controls, history, and countdown",
} as const;
diff --git a/src/features/cache-warming/logic/view-model.test.ts b/src/features/cache-warming/logic/view-model.test.ts
index d5ea901..39fec80 100644
--- a/src/features/cache-warming/logic/view-model.test.ts
+++ b/src/features/cache-warming/logic/view-model.test.ts
@@ -1,228 +1,228 @@
import type { SurfaceSpec } from "@dispatch/ui-contract";
import { describe, expect, it } from "vitest";
import {
- clampMinutes,
- clampSeconds,
- colorClass,
- formatCountdown,
- formatWarmLabel,
- fromMinSec,
- initialWarmingState,
- observeWarm,
- parseControls,
- parsePct,
- secondsUntilNext,
- statusForPct,
- toMinSec,
+ clampMinutes,
+ clampSeconds,
+ colorClass,
+ formatCountdown,
+ formatWarmLabel,
+ fromMinSec,
+ initialWarmingState,
+ observeWarm,
+ parseControls,
+ parsePct,
+ secondsUntilNext,
+ statusForPct,
+ toMinSec,
} from "./view-model";
const spec = (fields: SurfaceSpec["fields"]): SurfaceSpec => ({
- id: "cache-warming",
- region: "side",
- title: "Cache Warming",
- fields,
+ id: "cache-warming",
+ region: "side",
+ title: "Cache Warming",
+ fields,
});
describe("parsePct", () => {
- it("parses a percentage string", () => {
- expect(parsePct("100%")).toBe(100);
- expect(parsePct("93 %")).toBe(93);
- expect(parsePct("0%")).toBe(0);
- });
- it("returns null for a dash / non-numeric", () => {
- expect(parsePct("—")).toBeNull();
- expect(parsePct("n/a")).toBeNull();
- });
+ it("parses a percentage string", () => {
+ expect(parsePct("100%")).toBe(100);
+ expect(parsePct("93 %")).toBe(93);
+ expect(parsePct("0%")).toBe(0);
+ });
+ it("returns null for a dash / non-numeric", () => {
+ expect(parsePct("—")).toBeNull();
+ expect(parsePct("n/a")).toBeNull();
+ });
});
describe("parseControls", () => {
- it("returns empty defaults for a null spec", () => {
- const c = parseControls(null);
- expect(c).toEqual({
- enabled: false,
- toggleActionId: null,
- intervalSeconds: 0,
- setIntervalActionId: null,
- lastPct: null,
- retentionPct: null,
- nextWarmAt: null,
- lastWarmAt: null,
- });
- });
-
- it("extracts toggle / number / both stats / timer by kind", () => {
- const c = parseControls(
- spec([
- {
- kind: "toggle",
- label: "Enabled",
- value: true,
- action: { actionId: "cache-warming/toggle" },
- },
- {
- kind: "number",
- label: "Interval",
- value: 240,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- },
- { kind: "stat", label: "Last cache rate", value: "61%" },
- { kind: "stat", label: "Cache retention", value: "100%" },
- {
- kind: "custom",
- rendererId: "cache-warming-timer",
- payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 },
- },
- ]),
- );
- expect(c).toEqual({
- enabled: true,
- toggleActionId: "cache-warming/toggle",
- intervalSeconds: 240,
- setIntervalActionId: "cache-warming/set-interval",
- lastPct: 61,
- retentionPct: 100,
- nextWarmAt: 1_700_000_240_000,
- lastWarmAt: 1_700_000_000_000,
- });
- });
-
- it("tells the retention stat apart from the rate stat by label", () => {
- const c = parseControls(
- spec([
- { kind: "stat", label: "Cache retention", value: "100%" },
- { kind: "stat", label: "Last cache rate", value: "61%" },
- ]),
- );
- expect(c.retentionPct).toBe(100);
- expect(c.lastPct).toBe(61);
- });
-
- it("treats a '—' stat as no pct", () => {
- const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }]));
- expect(c.lastPct).toBeNull();
- });
-
- it("ignores an unknown custom renderer and a malformed timer payload", () => {
- const c = parseControls(
- spec([
- { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } },
- { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" },
- ]),
- );
- expect(c.nextWarmAt).toBeNull();
- expect(c.lastWarmAt).toBeNull();
- });
+ it("returns empty defaults for a null spec", () => {
+ const c = parseControls(null);
+ expect(c).toEqual({
+ enabled: false,
+ toggleActionId: null,
+ intervalSeconds: 0,
+ setIntervalActionId: null,
+ lastPct: null,
+ retentionPct: null,
+ nextWarmAt: null,
+ lastWarmAt: null,
+ });
+ });
+
+ it("extracts toggle / number / both stats / timer by kind", () => {
+ const c = parseControls(
+ spec([
+ {
+ kind: "toggle",
+ label: "Enabled",
+ value: true,
+ action: { actionId: "cache-warming/toggle" },
+ },
+ {
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ },
+ { kind: "stat", label: "Last cache rate", value: "61%" },
+ { kind: "stat", label: "Cache retention", value: "100%" },
+ {
+ kind: "custom",
+ rendererId: "cache-warming-timer",
+ payload: { nextWarmAt: 1_700_000_240_000, lastWarmAt: 1_700_000_000_000 },
+ },
+ ]),
+ );
+ expect(c).toEqual({
+ enabled: true,
+ toggleActionId: "cache-warming/toggle",
+ intervalSeconds: 240,
+ setIntervalActionId: "cache-warming/set-interval",
+ lastPct: 61,
+ retentionPct: 100,
+ nextWarmAt: 1_700_000_240_000,
+ lastWarmAt: 1_700_000_000_000,
+ });
+ });
+
+ it("tells the retention stat apart from the rate stat by label", () => {
+ const c = parseControls(
+ spec([
+ { kind: "stat", label: "Cache retention", value: "100%" },
+ { kind: "stat", label: "Last cache rate", value: "61%" },
+ ]),
+ );
+ expect(c.retentionPct).toBe(100);
+ expect(c.lastPct).toBe(61);
+ });
+
+ it("treats a '—' stat as no pct", () => {
+ const c = parseControls(spec([{ kind: "stat", label: "Last cache rate", value: "—" }]));
+ expect(c.lastPct).toBeNull();
+ });
+
+ it("ignores an unknown custom renderer and a malformed timer payload", () => {
+ const c = parseControls(
+ spec([
+ { kind: "custom", rendererId: "something-else", payload: { nextWarmAt: 5 } },
+ { kind: "custom", rendererId: "cache-warming-timer", payload: "nope" },
+ ]),
+ );
+ expect(c.nextWarmAt).toBeNull();
+ expect(c.lastWarmAt).toBeNull();
+ });
});
describe("interval ↔ min/sec", () => {
- it("clampSeconds caps at 0..59", () => {
- expect(clampSeconds(75)).toBe(59);
- expect(clampSeconds(-3)).toBe(0);
- expect(clampSeconds(30)).toBe(30);
- expect(clampSeconds(Number.NaN)).toBe(0);
- });
- it("clampMinutes floors at 0", () => {
- expect(clampMinutes(-1)).toBe(0);
- expect(clampMinutes(4)).toBe(4);
- });
- it("toMinSec splits total seconds", () => {
- expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 });
- expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 });
- expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 });
- });
- it("fromMinSec combines (clamping seconds to 59)", () => {
- expect(fromMinSec(4, 0)).toBe(240);
- expect(fromMinSec(2, 5)).toBe(125);
- expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59
- });
+ it("clampSeconds caps at 0..59", () => {
+ expect(clampSeconds(75)).toBe(59);
+ expect(clampSeconds(-3)).toBe(0);
+ expect(clampSeconds(30)).toBe(30);
+ expect(clampSeconds(Number.NaN)).toBe(0);
+ });
+ it("clampMinutes floors at 0", () => {
+ expect(clampMinutes(-1)).toBe(0);
+ expect(clampMinutes(4)).toBe(4);
+ });
+ it("toMinSec splits total seconds", () => {
+ expect(toMinSec(240)).toEqual({ minutes: 4, seconds: 0 });
+ expect(toMinSec(125)).toEqual({ minutes: 2, seconds: 5 });
+ expect(toMinSec(45)).toEqual({ minutes: 0, seconds: 45 });
+ });
+ it("fromMinSec combines (clamping seconds to 59)", () => {
+ expect(fromMinSec(4, 0)).toBe(240);
+ expect(fromMinSec(2, 5)).toBe(125);
+ expect(fromMinSec(1, 75)).toBe(119); // 75s clamped to 59
+ });
});
describe("status + formatting", () => {
- it("statusForPct buckets high/mid/low", () => {
- expect(statusForPct(100)).toBe("success");
- expect(statusForPct(80)).toBe("success");
- expect(statusForPct(60)).toBe("warning");
- expect(statusForPct(40)).toBe("warning");
- expect(statusForPct(10)).toBe("error");
- });
- it("colorClass maps to literal DaisyUI classes", () => {
- expect(colorClass("success")).toBe("text-success");
- expect(colorClass("warning")).toBe("text-warning");
- expect(colorClass("error")).toBe("text-error");
- });
- it("formatWarmLabel matches the manual-warm phrasing", () => {
- expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit");
- expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit");
- });
- it("formatCountdown renders s and m:ss", () => {
- expect(formatCountdown(9)).toBe("9s");
- expect(formatCountdown(59)).toBe("59s");
- expect(formatCountdown(60)).toBe("1:00");
- expect(formatCountdown(185)).toBe("3:05");
- expect(formatCountdown(-5)).toBe("0s");
- });
+ it("statusForPct buckets high/mid/low", () => {
+ expect(statusForPct(100)).toBe("success");
+ expect(statusForPct(80)).toBe("success");
+ expect(statusForPct(60)).toBe("warning");
+ expect(statusForPct(40)).toBe("warning");
+ expect(statusForPct(10)).toBe("error");
+ });
+ it("colorClass maps to literal DaisyUI classes", () => {
+ expect(colorClass("success")).toBe("text-success");
+ expect(colorClass("warning")).toBe("text-warning");
+ expect(colorClass("error")).toBe("text-error");
+ });
+ it("formatWarmLabel matches the manual-warm phrasing", () => {
+ expect(formatWarmLabel(100)).toBe("Warmed — 100% cache hit");
+ expect(formatWarmLabel(92.6)).toBe("Warmed — 93% cache hit");
+ });
+ it("formatCountdown renders s and m:ss", () => {
+ expect(formatCountdown(9)).toBe("9s");
+ expect(formatCountdown(59)).toBe("59s");
+ expect(formatCountdown(60)).toBe("1:00");
+ expect(formatCountdown(185)).toBe("3:05");
+ expect(formatCountdown(-5)).toBe("0s");
+ });
});
describe("warming history reducer (observeWarm)", () => {
- it("starts empty", () => {
- const s = initialWarmingState();
- expect(s.history).toEqual([]);
- expect(s.lastWarmAt).toBeNull();
- });
-
- it("records a new entry on each new authoritative lastWarmAt", () => {
- let s = initialWarmingState();
- s = observeWarm(s, 1000, 100);
- s = observeWarm(s, 2000, 90);
- expect(s.history).toEqual([
- { pct: 90, at: 2000 },
- { pct: 100, at: 1000 },
- ]);
- expect(s.lastWarmAt).toBe(2000);
- });
-
- it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => {
- let s = initialWarmingState();
- s = observeWarm(s, 1000, 100); // warm
- s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip
- expect(s.history).toHaveLength(1);
- });
-
- it("records two warms with the SAME pct (distinct timestamps both count)", () => {
- let s = initialWarmingState();
- s = observeWarm(s, 1000, 100);
- s = observeWarm(s, 2000, 100);
- expect(s.history.map((e) => e.at)).toEqual([2000, 1000]);
- });
-
- it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => {
- let s = initialWarmingState();
- s = observeWarm(s, null, 100);
- expect(s.history).toEqual([]);
- s = observeWarm(s, 1000, null);
- expect(s.history).toEqual([]);
- expect(s.lastWarmAt).toBe(1000);
- });
+ it("starts empty", () => {
+ const s = initialWarmingState();
+ expect(s.history).toEqual([]);
+ expect(s.lastWarmAt).toBeNull();
+ });
+
+ it("records a new entry on each new authoritative lastWarmAt", () => {
+ let s = initialWarmingState();
+ s = observeWarm(s, 1000, 100);
+ s = observeWarm(s, 2000, 90);
+ expect(s.history).toEqual([
+ { pct: 90, at: 2000 },
+ { pct: 100, at: 1000 },
+ ]);
+ expect(s.lastWarmAt).toBe(2000);
+ });
+
+ it("de-duplicates on the timestamp, not the pct (a re-pushed surface → no dup)", () => {
+ let s = initialWarmingState();
+ s = observeWarm(s, 1000, 100); // warm
+ s = observeWarm(s, 1000, 100); // toggle/interval re-push, same lastWarmAt → skip
+ expect(s.history).toHaveLength(1);
+ });
+
+ it("records two warms with the SAME pct (distinct timestamps both count)", () => {
+ let s = initialWarmingState();
+ s = observeWarm(s, 1000, 100);
+ s = observeWarm(s, 2000, 100);
+ expect(s.history.map((e) => e.at)).toEqual([2000, 1000]);
+ });
+
+ it("ignores a null lastWarmAt; a null pct advances the key without an entry", () => {
+ let s = initialWarmingState();
+ s = observeWarm(s, null, 100);
+ expect(s.history).toEqual([]);
+ s = observeWarm(s, 1000, null);
+ expect(s.history).toEqual([]);
+ expect(s.lastWarmAt).toBe(1000);
+ });
});
describe("secondsUntilNext (authoritative, from nextWarmAt)", () => {
- it("is null when nothing is scheduled (nextWarmAt null)", () => {
- expect(secondsUntilNext(null, 5000)).toBeNull();
- });
-
- it("counts down to nextWarmAt, floored at 0", () => {
- expect(secondsUntilNext(10_000, 10_000)).toBe(0);
- expect(secondsUntilNext(250_000, 10_000)).toBe(240);
- expect(secondsUntilNext(70_000, 10_000)).toBe(60);
- });
-
- it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => {
- // Within the 3s grace an on-time warm may briefly read "0s"…
- expect(secondsUntilNext(10_000, 11_000)).toBe(0);
- expect(secondsUntilNext(10_000, 13_000)).toBe(0);
- // …but beyond it the value is stale → null (the "waiting…" state).
- expect(secondsUntilNext(10_000, 13_001)).toBeNull();
- expect(secondsUntilNext(5_000, 999_999)).toBeNull();
- });
+ it("is null when nothing is scheduled (nextWarmAt null)", () => {
+ expect(secondsUntilNext(null, 5000)).toBeNull();
+ });
+
+ it("counts down to nextWarmAt, floored at 0", () => {
+ expect(secondsUntilNext(10_000, 10_000)).toBe(0);
+ expect(secondsUntilNext(250_000, 10_000)).toBe(240);
+ expect(secondsUntilNext(70_000, 10_000)).toBe(60);
+ });
+
+ it("treats a nextWarmAt past the stale grace as not scheduled (belt-and-braces)", () => {
+ // Within the 3s grace an on-time warm may briefly read "0s"…
+ expect(secondsUntilNext(10_000, 11_000)).toBe(0);
+ expect(secondsUntilNext(10_000, 13_000)).toBe(0);
+ // …but beyond it the value is stale → null (the "waiting…" state).
+ expect(secondsUntilNext(10_000, 13_001)).toBeNull();
+ expect(secondsUntilNext(5_000, 999_999)).toBeNull();
+ });
});
diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts
index eb105f6..bc8cf2e 100644
--- a/src/features/cache-warming/logic/view-model.ts
+++ b/src/features/cache-warming/logic/view-model.ts
@@ -15,37 +15,37 @@ import type { SurfaceSpec } from "@dispatch/ui-contract";
// ── Manual-warm port (consumer-defines-port; the composition root adapts the
// store's `POST /chat/warm` result to this shape). ──────────────────────────
export type WarmFeedback =
- | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number }
+ | { readonly ok: false; readonly error: string };
export type WarmNow = () => Promise<WarmFeedback | null>;
// ── Parsed surface controls ───────────────────────────────────────────────────
export interface ParsedControls {
- readonly enabled: boolean;
- readonly toggleActionId: string | null;
- readonly intervalSeconds: number;
- readonly setIntervalActionId: string | null;
- /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */
- readonly lastPct: number | null;
- /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */
- readonly retentionPct: number | null;
- /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */
- readonly nextWarmAt: number | null;
- /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */
- readonly lastWarmAt: number | null;
+ readonly enabled: boolean;
+ readonly toggleActionId: string | null;
+ readonly intervalSeconds: number;
+ readonly setIntervalActionId: string | null;
+ /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */
+ readonly lastPct: number | null;
+ /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */
+ readonly retentionPct: number | null;
+ /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */
+ readonly nextWarmAt: number | null;
+ /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */
+ readonly lastWarmAt: number | null;
}
const EMPTY_CONTROLS: ParsedControls = {
- enabled: false,
- toggleActionId: null,
- intervalSeconds: 0,
- setIntervalActionId: null,
- lastPct: null,
- retentionPct: null,
- nextWarmAt: null,
- lastWarmAt: null,
+ enabled: false,
+ toggleActionId: null,
+ intervalSeconds: 0,
+ setIntervalActionId: null,
+ lastPct: null,
+ retentionPct: null,
+ nextWarmAt: null,
+ lastWarmAt: null,
};
/** The `cache-warming-timer` custom field's renderer id (this feature owns it). */
@@ -53,24 +53,24 @@ const TIMER_RENDERER_ID = "cache-warming-timer";
/** Parse a stat's display string (e.g. "100%", "93 %", "—") into a number or null. */
export function parsePct(value: string): number | null {
- const match = value.match(/-?\d+(?:\.\d+)?/);
- if (match === null) return null;
- const n = Number(match[0]);
- return Number.isFinite(n) ? n : null;
+ const match = value.match(/-?\d+(?:\.\d+)?/);
+ if (match === null) return null;
+ const n = Number(match[0]);
+ return Number.isFinite(n) ? n : null;
}
/** A finite number, else null. */
function numOrNull(v: unknown): number | null {
- return typeof v === "number" && Number.isFinite(v) ? v : null;
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */
function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } {
- if (typeof payload !== "object" || payload === null) {
- return { nextWarmAt: null, lastWarmAt: null };
- }
- const p = payload as Record<string, unknown>;
- return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) };
+ if (typeof payload !== "object" || payload === null) {
+ return { nextWarmAt: null, lastWarmAt: null };
+ }
+ const p = payload as Record<string, unknown>;
+ return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) };
}
/**
@@ -80,79 +80,79 @@ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt:
* absent.
*/
export function parseControls(spec: SurfaceSpec | null): ParsedControls {
- if (spec === null) return EMPTY_CONTROLS;
- let enabled = false;
- let toggleActionId: string | null = null;
- let intervalSeconds = 0;
- let setIntervalActionId: string | null = null;
- let lastPct: number | null = null;
- let retentionPct: number | null = null;
- let nextWarmAt: number | null = null;
- let lastWarmAt: number | null = null;
- let seenToggle = false;
- let seenNumber = false;
- let seenRateStat = false;
- for (const field of spec.fields) {
- if (field.kind === "toggle" && !seenToggle) {
- enabled = field.value;
- toggleActionId = field.action.actionId;
- seenToggle = true;
- } else if (field.kind === "number" && !seenNumber) {
- intervalSeconds = field.value;
- setIntervalActionId = field.action.actionId;
- seenNumber = true;
- } else if (field.kind === "stat") {
- // Retention is told apart by its label; everything else is the cache rate
- // (first one wins, so a stray later stat can't clobber it).
- if (/retention/i.test(field.label)) {
- retentionPct = parsePct(field.value);
- } else if (!seenRateStat) {
- lastPct = parsePct(field.value);
- seenRateStat = true;
- }
- } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) {
- const timer = parseTimer(field.payload);
- nextWarmAt = timer.nextWarmAt;
- lastWarmAt = timer.lastWarmAt;
- }
- }
- return {
- enabled,
- toggleActionId,
- intervalSeconds,
- setIntervalActionId,
- lastPct,
- retentionPct,
- nextWarmAt,
- lastWarmAt,
- };
+ if (spec === null) return EMPTY_CONTROLS;
+ let enabled = false;
+ let toggleActionId: string | null = null;
+ let intervalSeconds = 0;
+ let setIntervalActionId: string | null = null;
+ let lastPct: number | null = null;
+ let retentionPct: number | null = null;
+ let nextWarmAt: number | null = null;
+ let lastWarmAt: number | null = null;
+ let seenToggle = false;
+ let seenNumber = false;
+ let seenRateStat = false;
+ for (const field of spec.fields) {
+ if (field.kind === "toggle" && !seenToggle) {
+ enabled = field.value;
+ toggleActionId = field.action.actionId;
+ seenToggle = true;
+ } else if (field.kind === "number" && !seenNumber) {
+ intervalSeconds = field.value;
+ setIntervalActionId = field.action.actionId;
+ seenNumber = true;
+ } else if (field.kind === "stat") {
+ // Retention is told apart by its label; everything else is the cache rate
+ // (first one wins, so a stray later stat can't clobber it).
+ if (/retention/i.test(field.label)) {
+ retentionPct = parsePct(field.value);
+ } else if (!seenRateStat) {
+ lastPct = parsePct(field.value);
+ seenRateStat = true;
+ }
+ } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) {
+ const timer = parseTimer(field.payload);
+ nextWarmAt = timer.nextWarmAt;
+ lastWarmAt = timer.lastWarmAt;
+ }
+ }
+ return {
+ enabled,
+ toggleActionId,
+ intervalSeconds,
+ setIntervalActionId,
+ lastPct,
+ retentionPct,
+ nextWarmAt,
+ lastWarmAt,
+ };
}
// ── Interval ↔ minutes/seconds (seconds capped at 59) ─────────────────────────
export interface MinSec {
- readonly minutes: number;
- readonly seconds: number;
+ readonly minutes: number;
+ readonly seconds: number;
}
export function clampSeconds(n: number): number {
- if (!Number.isFinite(n)) return 0;
- return Math.min(59, Math.max(0, Math.floor(n)));
+ if (!Number.isFinite(n)) return 0;
+ return Math.min(59, Math.max(0, Math.floor(n)));
}
export function clampMinutes(n: number): number {
- if (!Number.isFinite(n)) return 0;
- return Math.max(0, Math.floor(n));
+ if (!Number.isFinite(n)) return 0;
+ return Math.max(0, Math.floor(n));
}
export function toMinSec(totalSeconds: number): MinSec {
- const total = Math.max(0, Math.floor(totalSeconds));
- return { minutes: Math.floor(total / 60), seconds: total % 60 };
+ const total = Math.max(0, Math.floor(totalSeconds));
+ return { minutes: Math.floor(total / 60), seconds: total % 60 };
}
/** Combine a minutes + seconds pair (each clamped) into total seconds. */
export function fromMinSec(minutes: number, seconds: number): number {
- return clampMinutes(minutes) * 60 + clampSeconds(seconds);
+ return clampMinutes(minutes) * 60 + clampSeconds(seconds);
}
// ── Status + formatting ───────────────────────────────────────────────────────
@@ -161,56 +161,56 @@ export type WarmStatus = "success" | "warning" | "error";
/** Cache-hit % → semantic status (green high, yellow mid, red low). */
export function statusForPct(pct: number): WarmStatus {
- if (pct >= 80) return "success";
- if (pct >= 40) return "warning";
- return "error";
+ if (pct >= 80) return "success";
+ if (pct >= 40) return "warning";
+ return "error";
}
/** A status → its DaisyUI text-colour class (full literal so Tailwind keeps it). */
export function colorClass(status: WarmStatus): string {
- switch (status) {
- case "success":
- return "text-success";
- case "warning":
- return "text-warning";
- case "error":
- return "text-error";
- }
+ switch (status) {
+ case "success":
+ return "text-success";
+ case "warning":
+ return "text-warning";
+ case "error":
+ return "text-error";
+ }
}
/** The status line for a warm, matching the manual-warm feedback phrasing. */
export function formatWarmLabel(pct: number): string {
- return `Warmed — ${Math.round(pct)}% cache hit`;
+ return `Warmed — ${Math.round(pct)}% cache hit`;
}
/** Seconds → a short countdown string (e.g. "3:05", "9s"). */
export function formatCountdown(seconds: number): string {
- const s = Math.max(0, Math.floor(seconds));
- if (s < 60) return `${s}s`;
- const m = Math.floor(s / 60);
- const rem = s % 60;
- return `${m}:${String(rem).padStart(2, "0")}`;
+ const s = Math.max(0, Math.floor(seconds));
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rem = s % 60;
+ return `${m}:${String(rem).padStart(2, "0")}`;
}
// ── Warming history reducer (keyed off the authoritative `lastWarmAt`) ─────────
export interface WarmEntry {
- readonly pct: number;
- /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */
- readonly at: number;
+ readonly pct: number;
+ /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */
+ readonly at: number;
}
export interface WarmingViewState {
- /** Warmings, MOST RECENT FIRST. */
- readonly history: readonly WarmEntry[];
- /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */
- readonly lastWarmAt: number | null;
+ /** Warmings, MOST RECENT FIRST. */
+ readonly history: readonly WarmEntry[];
+ /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */
+ readonly lastWarmAt: number | null;
}
const MAX_HISTORY = 50;
export function initialWarmingState(): WarmingViewState {
- return { history: [], lastWarmAt: null };
+ return { history: [], lastWarmAt: null };
}
/**
@@ -221,14 +221,14 @@ export function initialWarmingState(): WarmingViewState {
* ignored; a null pct advances the de-dup key without adding an entry.
*/
export function observeWarm(
- state: WarmingViewState,
- lastWarmAt: number | null,
- pct: number | null,
+ state: WarmingViewState,
+ lastWarmAt: number | null,
+ pct: number | null,
): WarmingViewState {
- if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state;
- if (pct === null) return { ...state, lastWarmAt };
- const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY);
- return { history, lastWarmAt };
+ if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state;
+ if (pct === null) return { ...state, lastWarmAt };
+ const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY);
+ return { history, lastWarmAt };
}
/**
@@ -248,7 +248,7 @@ const STALE_NEXT_WARM_MS = 3000;
* when `nextWarmAt` is stale (further than the grace into the past).
*/
export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null {
- if (nextWarmAt === null) return null;
- if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null;
- return Math.max(0, Math.ceil((nextWarmAt - now) / 1000));
+ if (nextWarmAt === null) return null;
+ if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null;
+ return Math.max(0, Math.ceil((nextWarmAt - now) / 1000));
}
diff --git a/src/features/cache-warming/ui/CacheWarmingView.svelte b/src/features/cache-warming/ui/CacheWarmingView.svelte
index ced5e99..9b3d694 100644
--- a/src/features/cache-warming/ui/CacheWarmingView.svelte
+++ b/src/features/cache-warming/ui/CacheWarmingView.svelte
@@ -1,234 +1,244 @@
<script lang="ts">
- import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { onMount, untrack } from "svelte";
- import {
- clampMinutes,
- clampSeconds,
- colorClass,
- formatCountdown,
- formatWarmLabel,
- fromMinSec,
- initialWarmingState,
- observeWarm,
- parseControls,
- secondsUntilNext,
- statusForPct,
- toMinSec,
- type WarmingViewState,
- type WarmNow,
- } from "../logic/view-model";
-
- let {
- spec,
- canWarm,
- onInvoke,
- warmNow,
- }: {
- /** The cache-warming surface spec for the focused conversation, or null. */
- spec: SurfaceSpec | null;
- /** Whether a real conversation is focused (a draft has nothing to warm). */
- canWarm: boolean;
- onInvoke: (msg: InvokeMessage) => void;
- warmNow: WarmNow;
- } = $props();
-
- const controls = $derived(parseControls(spec));
-
- // View-model state (pure reducer) + the injected clock — owned here, not ambient.
- let vm = $state<WarmingViewState>(initialWarmingState());
- let now = $state(Date.now());
- let warming = $state(false);
- let errorText = $state<string | null>(null);
- // Transient result of the most recent manual warm (immediate feedback; history
- // itself is driven authoritatively by the surface's `lastWarmAt`).
- let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null);
-
- // Local interval inputs, seeded from the surface and re-seeded only when the
- // surface's interval differs from what's shown (so a stray update mid-edit
- // doesn't clobber typing).
- let minutes = $state(0);
- let seconds = $state(0);
-
- onMount(() => {
- const id = setInterval(() => {
- now = Date.now();
- }, 1000);
- return () => clearInterval(id);
- });
-
- // Fold each authoritative warm (new `lastWarmAt`) into history.
- $effect(() => {
- const at = controls.lastWarmAt;
- const pct = controls.lastPct;
- untrack(() => {
- vm = observeWarm(vm, at, pct);
- });
- });
-
- // Keep the min/sec inputs in sync with the surface's interval.
- $effect(() => {
- const target = controls.intervalSeconds;
- untrack(() => {
- if (fromMinSec(minutes, seconds) !== target) {
- const ms = toMinSec(target);
- minutes = ms.minutes;
- seconds = ms.seconds;
- }
- });
- });
-
- const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now));
- const history = $derived(vm.history);
- const latest = $derived(history[0] ?? null);
- const earlier = $derived(history.slice(1));
-
- function commitInterval() {
- const actionId = controls.setIntervalActionId;
- if (actionId === null || spec === null) return;
- onInvoke({ type: "invoke", surfaceId: spec.id, actionId, payload: fromMinSec(minutes, seconds) });
- }
-
- function onMinutes(event: Event) {
- const next = (event.target as HTMLInputElement).valueAsNumber;
- if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
- minutes = clampMinutes(next);
- commitInterval();
- }
-
- function onSeconds(event: Event) {
- const next = (event.target as HTMLInputElement).valueAsNumber;
- if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
- seconds = clampSeconds(next);
- commitInterval();
- }
-
- function onToggle() {
- const actionId = controls.toggleActionId;
- if (actionId === null || spec === null) return;
- // The toggle action FLIPS server-side; no payload.
- onInvoke({ type: "invoke", surfaceId: spec.id, actionId });
- }
-
- async function handleWarm() {
- if (warming) return;
- warming = true;
- errorText = null;
- const result = await warmNow();
- warming = false;
- if (result === null) return;
- if (result.ok) {
- // Immediate feedback only — the authoritative surface `update` (new
- // `lastWarmAt`) drives the history via `observeWarm`.
- manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate };
- } else {
- manualResult = null;
- errorText = result.error;
- }
- }
+ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+ import { onMount, untrack } from "svelte";
+ import {
+ clampMinutes,
+ clampSeconds,
+ colorClass,
+ formatCountdown,
+ formatWarmLabel,
+ fromMinSec,
+ initialWarmingState,
+ observeWarm,
+ parseControls,
+ secondsUntilNext,
+ statusForPct,
+ toMinSec,
+ type WarmingViewState,
+ type WarmNow,
+ } from "../logic/view-model";
+
+ let {
+ spec,
+ canWarm,
+ onInvoke,
+ warmNow,
+ }: {
+ /** The cache-warming surface spec for the focused conversation, or null. */
+ spec: SurfaceSpec | null;
+ /** Whether a real conversation is focused (a draft has nothing to warm). */
+ canWarm: boolean;
+ onInvoke: (msg: InvokeMessage) => void;
+ warmNow: WarmNow;
+ } = $props();
+
+ const controls = $derived(parseControls(spec));
+
+ // View-model state (pure reducer) + the injected clock — owned here, not ambient.
+ let vm = $state<WarmingViewState>(initialWarmingState());
+ let now = $state(Date.now());
+ let warming = $state(false);
+ let errorText = $state<string | null>(null);
+ // Transient result of the most recent manual warm (immediate feedback; history
+ // itself is driven authoritatively by the surface's `lastWarmAt`).
+ let manualResult = $state<{ cachePct: number; expectedCacheRate: number } | null>(null);
+
+ // Local interval inputs, seeded from the surface and re-seeded only when the
+ // surface's interval differs from what's shown (so a stray update mid-edit
+ // doesn't clobber typing).
+ let minutes = $state(0);
+ let seconds = $state(0);
+
+ onMount(() => {
+ const id = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(id);
+ });
+
+ // Fold each authoritative warm (new `lastWarmAt`) into history.
+ $effect(() => {
+ const at = controls.lastWarmAt;
+ const pct = controls.lastPct;
+ untrack(() => {
+ vm = observeWarm(vm, at, pct);
+ });
+ });
+
+ // Keep the min/sec inputs in sync with the surface's interval.
+ $effect(() => {
+ const target = controls.intervalSeconds;
+ untrack(() => {
+ if (fromMinSec(minutes, seconds) !== target) {
+ const ms = toMinSec(target);
+ minutes = ms.minutes;
+ seconds = ms.seconds;
+ }
+ });
+ });
+
+ const remaining = $derived(secondsUntilNext(controls.nextWarmAt, now));
+ const history = $derived(vm.history);
+ const latest = $derived(history[0] ?? null);
+ const earlier = $derived(history.slice(1));
+
+ function commitInterval() {
+ const actionId = controls.setIntervalActionId;
+ if (actionId === null || spec === null) return;
+ onInvoke({
+ type: "invoke",
+ surfaceId: spec.id,
+ actionId,
+ payload: fromMinSec(minutes, seconds),
+ });
+ }
+
+ function onMinutes(event: Event) {
+ const next = (event.target as HTMLInputElement).valueAsNumber;
+ if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
+ minutes = clampMinutes(next);
+ commitInterval();
+ }
+
+ function onSeconds(event: Event) {
+ const next = (event.target as HTMLInputElement).valueAsNumber;
+ if (Number.isNaN(next)) return; // empty input — ignore, don't clobber to 0
+ seconds = clampSeconds(next);
+ commitInterval();
+ }
+
+ function onToggle() {
+ const actionId = controls.toggleActionId;
+ if (actionId === null || spec === null) return;
+ // The toggle action FLIPS server-side; no payload.
+ onInvoke({ type: "invoke", surfaceId: spec.id, actionId });
+ }
+
+ async function handleWarm() {
+ if (warming) return;
+ warming = true;
+ errorText = null;
+ const result = await warmNow();
+ warming = false;
+ if (result === null) return;
+ if (result.ok) {
+ // Immediate feedback only — the authoritative surface `update` (new
+ // `lastWarmAt`) drives the history via `observeWarm`.
+ manualResult = { cachePct: result.cachePct, expectedCacheRate: result.expectedCacheRate };
+ } else {
+ manualResult = null;
+ errorText = result.error;
+ }
+ }
</script>
<div class="flex flex-col gap-3">
- <!-- Enabled -->
- <label class="flex items-center justify-between gap-2 text-sm">
- <span>Enabled</span>
- <input
- type="checkbox"
- class="toggle toggle-sm toggle-success"
- checked={controls.enabled}
- disabled={spec === null}
- onchange={onToggle}
- />
- </label>
-
- <!-- Refresh interval: minutes + seconds (seconds capped at 59) -->
- <div class="flex items-center justify-between gap-2 text-sm">
- <span>Refresh interval</span>
- <span class="flex items-center gap-1">
- <input
- type="number"
- class="input input-bordered input-sm w-16"
- min="0"
- value={minutes}
- disabled={spec === null}
- onchange={onMinutes}
- aria-label="Interval minutes"
- />
- <span class="opacity-60">m</span>
- <input
- type="number"
- class="input input-bordered input-sm w-16"
- min="0"
- max="59"
- value={seconds}
- disabled={spec === null}
- onchange={onSeconds}
- aria-label="Interval seconds"
- />
- <span class="opacity-60">s</span>
- </span>
- </div>
-
- <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) -->
- {#if !controls.enabled}
- <p class="text-xs opacity-50">Warming paused.</p>
- {:else if remaining !== null}
- <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p>
- {:else}
- <p class="text-xs opacity-50">Next warm: waiting…</p>
- {/if}
-
- <!-- Cross-turn retention (the "is warming working?" health signal) -->
- {#if controls.retentionPct !== null}
- <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}">
- Cache retention: {controls.retentionPct}%
- </p>
- {/if}
-
- <!-- Manual trigger -->
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={!canWarm || warming}
- onclick={handleWarm}
- >
- {#if warming}
- <span class="loading loading-spinner loading-xs"></span>
- Warming…
- {:else}
- Warm now
- {/if}
- </button>
-
- {#if !canWarm}
- <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p>
- {:else if errorText}
- <p class="text-xs text-error">{errorText}</p>
- {:else if manualResult}
- <!-- Headline the retention (cache health) over the raw hit %. -->
- <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}">
- Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt cached)
- </p>
- {/if}
-
- <!-- Warming history: collapse whose title is the most recent warm, coloured by
- hit %, with the earlier warmings inside. -->
- {#if latest}
- <div class="collapse collapse-arrow bg-base-200">
- <input type="checkbox" aria-label="Toggle warming history" />
- <div class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(statusForPct(latest.pct))}">
- {formatWarmLabel(latest.pct)}
- </div>
- <div class="collapse-content flex flex-col gap-1 text-sm">
- {#if earlier.length > 0}
- {#each earlier as entry, i (i)}
- <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p>
- {/each}
- {:else}
- <p class="text-xs opacity-60">No earlier warmings.</p>
- {/if}
- </div>
- </div>
- {:else}
- <p class="text-xs opacity-60">No warming yet.</p>
- {/if}
+ <!-- Enabled -->
+ <label class="flex items-center justify-between gap-2 text-sm">
+ <span>Enabled</span>
+ <input
+ type="checkbox"
+ class="toggle toggle-sm toggle-success"
+ checked={controls.enabled}
+ disabled={spec === null}
+ onchange={onToggle}
+ />
+ </label>
+
+ <!-- Refresh interval: minutes + seconds (seconds capped at 59) -->
+ <div class="flex items-center justify-between gap-2 text-sm">
+ <span>Refresh interval</span>
+ <span class="flex items-center gap-1">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-16"
+ min="0"
+ value={minutes}
+ disabled={spec === null}
+ onchange={onMinutes}
+ aria-label="Interval minutes"
+ />
+ <span class="opacity-60">m</span>
+ <input
+ type="number"
+ class="input input-bordered input-sm w-16"
+ min="0"
+ max="59"
+ value={seconds}
+ disabled={spec === null}
+ onchange={onSeconds}
+ aria-label="Interval seconds"
+ />
+ <span class="opacity-60">s</span>
+ </span>
+ </div>
+
+ <!-- Countdown to the next automatic warm (authoritative: driven by nextWarmAt) -->
+ {#if !controls.enabled}
+ <p class="text-xs opacity-50">Warming paused.</p>
+ {:else if remaining !== null}
+ <p class="text-xs opacity-70">Next warm in {formatCountdown(remaining)}</p>
+ {:else}
+ <p class="text-xs opacity-50">Next warm: waiting…</p>
+ {/if}
+
+ <!-- Cross-turn retention (the "is warming working?" health signal) -->
+ {#if controls.retentionPct !== null}
+ <p class="text-xs {colorClass(statusForPct(controls.retentionPct))}">
+ Cache retention: {controls.retentionPct}%
+ </p>
+ {/if}
+
+ <!-- Manual trigger -->
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canWarm || warming}
+ onclick={handleWarm}
+ >
+ {#if warming}
+ <span class="loading loading-spinner loading-xs"></span>
+ Warming…
+ {:else}
+ Warm now
+ {/if}
+ </button>
+
+ {#if !canWarm}
+ <p class="text-xs opacity-60">Open or start a conversation to control its cache warming.</p>
+ {:else if errorText}
+ <p class="text-xs text-error">{errorText}</p>
+ {:else if manualResult}
+ <!-- Headline the retention (cache health) over the raw hit %. -->
+ <p class="text-xs {colorClass(statusForPct(manualResult.expectedCacheRate))}">
+ Warmed — {manualResult.expectedCacheRate}% retained ({manualResult.cachePct}% of prompt
+ cached)
+ </p>
+ {/if}
+
+ <!-- Warming history: collapse whose title is the most recent warm, coloured by
+ hit %, with the earlier warmings inside. -->
+ {#if latest}
+ <div class="collapse collapse-arrow bg-base-200">
+ <input type="checkbox" aria-label="Toggle warming history" />
+ <div
+ class="collapse-title min-h-0 py-2 font-normal text-sm {colorClass(
+ statusForPct(latest.pct),
+ )}"
+ >
+ {formatWarmLabel(latest.pct)}
+ </div>
+ <div class="collapse-content flex flex-col gap-1 text-sm">
+ {#if earlier.length > 0}
+ {#each earlier as entry, i (i)}
+ <p class={colorClass(statusForPct(entry.pct))}>{formatWarmLabel(entry.pct)}</p>
+ {/each}
+ {:else}
+ <p class="text-xs opacity-60">No earlier warmings.</p>
+ {/if}
+ </div>
+ </div>
+ {:else}
+ <p class="text-xs opacity-60">No warming yet.</p>
+ {/if}
</div>
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index c120916..ddb094d 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,23 +1,23 @@
export type {
- ProviderRetryView,
- RenderedChunk,
- RenderGroup,
- ToolBatchEntry,
+ ProviderRetryView,
+ RenderedChunk,
+ RenderGroup,
+ ToolBatchEntry,
} from "../../core/chunks";
export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
- EffortOption,
- ReasoningEffortSaveResult,
- SaveReasoningEffort,
+ EffortOption,
+ ReasoningEffortSaveResult,
+ SaveReasoningEffort,
} from "./reasoning-effort";
export {
- DEFAULT_REASONING_EFFORT,
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- REASONING_EFFORT_LEVELS,
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ REASONING_EFFORT_LEVELS,
} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
@@ -30,6 +30,6 @@ export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "chat",
- description: "Conversation turns, composer, model selector, and metrics",
+ name: "chat",
+ description: "Conversation turns, composer, model selector, and metrics",
} as const;
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
index 109cae1..deb673d 100644
--- a/src/features/chat/model-select.test.ts
+++ b/src/features/chat/model-select.test.ts
@@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest";
import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select";
describe("splitModelName", () => {
- it("splits on the first slash", () => {
- expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" });
- });
-
- it("keeps slashes in the model part (splits only the first)", () => {
- expect(splitModelName("openrouter/anthropic/claude")).toEqual({
- key: "openrouter",
- model: "anthropic/claude",
- });
- });
-
- it("treats a slashless name as all key", () => {
- expect(splitModelName("local")).toEqual({ key: "local", model: "" });
- });
+ it("splits on the first slash", () => {
+ expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" });
+ });
+
+ it("keeps slashes in the model part (splits only the first)", () => {
+ expect(splitModelName("openrouter/anthropic/claude")).toEqual({
+ key: "openrouter",
+ model: "anthropic/claude",
+ });
+ });
+
+ it("treats a slashless name as all key", () => {
+ expect(splitModelName("local")).toEqual({ key: "local", model: "" });
+ });
});
describe("joinModelName", () => {
- it("recombines key + model", () => {
- expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4");
- });
-
- it("returns just the key when the model is empty", () => {
- expect(joinModelName("local", "")).toBe("local");
- });
-
- it("round-trips with splitModelName", () => {
- const full = "openrouter/anthropic/claude";
- const { key, model } = splitModelName(full);
- expect(joinModelName(key, model)).toBe(full);
- });
+ it("recombines key + model", () => {
+ expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4");
+ });
+
+ it("returns just the key when the model is empty", () => {
+ expect(joinModelName("local", "")).toBe("local");
+ });
+
+ it("round-trips with splitModelName", () => {
+ const full = "openrouter/anthropic/claude";
+ const { key, model } = splitModelName(full);
+ expect(joinModelName(key, model)).toBe(full);
+ });
});
describe("modelKeys", () => {
- it("returns distinct keys in first-seen order", () => {
- expect(
- modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]),
- ).toEqual(["openai", "anthropic", "google"]);
- });
-
- it("is empty for no models", () => {
- expect(modelKeys([])).toEqual([]);
- });
+ it("returns distinct keys in first-seen order", () => {
+ expect(
+ modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]),
+ ).toEqual(["openai", "anthropic", "google"]);
+ });
+
+ it("is empty for no models", () => {
+ expect(modelKeys([])).toEqual([]);
+ });
});
describe("modelsForKey", () => {
- it("returns the model suffixes under a key, in order", () => {
- const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
- expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]);
- });
-
- it("returns empty for an unknown key", () => {
- expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]);
- });
+ it("returns the model suffixes under a key, in order", () => {
+ const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
+ expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]);
+ });
+
+ it("returns empty for an unknown key", () => {
+ expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]);
+ });
});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index b1d70b9..db41772 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -8,42 +8,42 @@
*/
export interface SplitModel {
- readonly key: string;
- readonly model: string;
+ readonly key: string;
+ readonly model: string;
}
/** Split `<key>/<model>` on the first slash. A slashless name is all-key. */
export function splitModelName(full: string): SplitModel {
- const i = full.indexOf("/");
- if (i === -1) return { key: full, model: "" };
- return { key: full.slice(0, i), model: full.slice(i + 1) };
+ const i = full.indexOf("/");
+ if (i === -1) return { key: full, model: "" };
+ return { key: full.slice(0, i), model: full.slice(i + 1) };
}
/** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */
export function joinModelName(key: string, model: string): string {
- return model === "" ? key : `${key}/${model}`;
+ return model === "" ? key : `${key}/${model}`;
}
/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
- const seen = new Set<string>();
- const out: string[] = [];
- for (const full of models) {
- const { key } = splitModelName(full);
- if (!seen.has(key)) {
- seen.add(key);
- out.push(key);
- }
- }
- return out;
+ const seen = new Set<string>();
+ const out: string[] = [];
+ for (const full of models) {
+ const { key } = splitModelName(full);
+ if (!seen.has(key)) {
+ seen.add(key);
+ out.push(key);
+ }
+ }
+ return out;
}
/** The model suffixes available under a given key, in order. */
export function modelsForKey(models: readonly string[], key: string): string[] {
- const out: string[] = [];
- for (const full of models) {
- const split = splitModelName(full);
- if (split.key === key) out.push(split.model);
- }
- return out;
+ const out: string[] = [];
+ for (const full of models) {
+ const split = splitModelName(full);
+ if (split.key === key) out.push(split.model);
+ }
+ return out;
}
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index ffe2c94..2fe10dc 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,8 +1,8 @@
import type {
- ChatQueueMessage,
- ChatSendMessage,
- ConversationHistoryResponse,
- ConversationMetricsResponse,
+ ChatQueueMessage,
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
} from "@dispatch/transport-contract";
/**
@@ -11,7 +11,7 @@ import type {
* auto-starts a turn if idle).
*/
export interface ChatTransport {
- send(msg: ChatSendMessage | ChatQueueMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage): void;
}
/**
@@ -19,10 +19,10 @@ export interface ChatTransport {
* Both must be POSITIVE integers when present (the server 400s otherwise).
*/
export interface HistoryWindow {
- /** Return only the NEWEST `limit` chunks of the selection (still ascending). */
- readonly limit?: number;
- /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */
- readonly beforeSeq?: number;
+ /** Return only the NEWEST `limit` chunks of the selection (still ascending). */
+ readonly limit?: number;
+ /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */
+ readonly beforeSeq?: number;
}
/**
@@ -34,9 +34,9 @@ export interface HistoryWindow {
* satisfies this naturally).
*/
export type HistorySync = (
- conversationId: string,
- sinceSeq: number,
- window?: HistoryWindow,
+ conversationId: string,
+ sinceSeq: number,
+ window?: HistoryWindow,
) => Promise<ConversationHistoryResponse>;
/** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
index 8f76dea..6d409e9 100644
--- a/src/features/chat/reasoning-effort.test.ts
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -1,45 +1,45 @@
import { describe, expect, it } from "vitest";
import {
- DEFAULT_REASONING_EFFORT,
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- REASONING_EFFORT_LEVELS,
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ REASONING_EFFORT_LEVELS,
} from "./reasoning-effort";
describe("reasoning-effort helpers", () => {
- it("ladder matches the wire contract, in ascending depth order", () => {
- expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]);
- });
+ it("ladder matches the wire contract, in ascending depth order", () => {
+ expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ });
- it("the server default is high", () => {
- expect(DEFAULT_REASONING_EFFORT).toBe("high");
- });
+ it("the server default is high", () => {
+ expect(DEFAULT_REASONING_EFFORT).toBe("high");
+ });
- it("isReasoningEffort narrows ladder strings and rejects everything else", () => {
- for (const level of REASONING_EFFORT_LEVELS) {
- expect(isReasoningEffort(level)).toBe(true);
- }
- expect(isReasoningEffort("banana")).toBe(false);
- expect(isReasoningEffort("")).toBe(false);
- expect(isReasoningEffort("HIGH")).toBe(false);
- });
+ it("isReasoningEffort narrows ladder strings and rejects everything else", () => {
+ for (const level of REASONING_EFFORT_LEVELS) {
+ expect(isReasoningEffort(level)).toBe(true);
+ }
+ expect(isReasoningEffort("banana")).toBe(false);
+ expect(isReasoningEffort("")).toBe(false);
+ expect(isReasoningEffort("HIGH")).toBe(false);
+ });
- it("effectiveEffort maps null (never set) to the default, not 'off'", () => {
- expect(effectiveEffort(null)).toBe("high");
- });
+ it("effectiveEffort maps null (never set) to the default, not 'off'", () => {
+ expect(effectiveEffort(null)).toBe("high");
+ });
- it("effectiveEffort passes a persisted value through", () => {
- expect(effectiveEffort("xhigh")).toBe("xhigh");
- expect(effectiveEffort("low")).toBe("low");
- });
+ it("effectiveEffort passes a persisted value through", () => {
+ expect(effectiveEffort("xhigh")).toBe("xhigh");
+ expect(effectiveEffort("low")).toBe("low");
+ });
- it("effortOptions lists every level once and marks only the default", () => {
- const options = effortOptions();
- expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
- expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
- for (const option of options) {
- if (option.value !== "high") expect(option.label).toBe(option.value);
- }
- });
+ it("effortOptions lists every level once and marks only the default", () => {
+ const options = effortOptions();
+ expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
+ expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
+ for (const option of options) {
+ if (option.value !== "high") expect(option.label).toBe(option.value);
+ }
+ });
});
diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts
index 2a55089..1eb77b6 100644
--- a/src/features/chat/reasoning-effort.ts
+++ b/src/features/chat/reasoning-effort.ts
@@ -13,11 +13,11 @@ import type { ReasoningEffort } from "@dispatch/transport-contract";
/** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */
export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [
- "low",
- "medium",
- "high",
- "xhigh",
- "max",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
];
/** The server's fallback when nothing is set (the resolution chain's tail). */
@@ -25,7 +25,7 @@ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high";
/** Narrow an untrusted string (e.g. a `<select>` value) to the ladder. */
export function isReasoningEffort(value: string): value is ReasoningEffort {
- return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value);
+ return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
@@ -33,13 +33,13 @@ export function isReasoningEffort(value: string): value is ReasoningEffort {
* server default when never set (`null` = "default applies", not "off").
*/
export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort {
- return persisted ?? DEFAULT_REASONING_EFFORT;
+ return persisted ?? DEFAULT_REASONING_EFFORT;
}
/** One `<option>` of the selector. */
export interface EffortOption {
- readonly value: ReasoningEffort;
- readonly label: string;
+ readonly value: ReasoningEffort;
+ readonly label: string;
}
/**
@@ -47,10 +47,10 @@ export interface EffortOption {
* `(default)` so a never-set conversation reads "high (default)".
*/
export function effortOptions(): readonly EffortOption[] {
- return REASONING_EFFORT_LEVELS.map((level) => ({
- value: level,
- label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level,
- }));
+ return REASONING_EFFORT_LEVELS.map((level) => ({
+ value: level,
+ label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level,
+ }));
}
// ── Injected port (consumer-defines-port; the composition root adapts the
@@ -58,9 +58,9 @@ export function effortOptions(): readonly EffortOption[] {
/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
export type ReasoningEffortSaveResult =
- | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
export type SaveReasoningEffort = (
- level: ReasoningEffort,
+ level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index a31fe55..3588da1 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,398 +1,398 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ChatQueueMessage,
- ChatSendMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
import {
- appendUserMessage,
- applyHistory,
- clearGenerating,
- foldEvent,
- initialState,
- initialWindowSize,
- normalizeChatLimit,
- restoreEarlier,
- selectChunks,
- selectGenerating,
- selectHasEarlier,
- selectMessages,
- selectProviderRetry,
- trimTranscript,
- unloadCount,
- windowTranscript,
+ appendUserMessage,
+ applyHistory,
+ clearGenerating,
+ foldEvent,
+ initialState,
+ initialWindowSize,
+ normalizeChatLimit,
+ restoreEarlier,
+ selectChunks,
+ selectGenerating,
+ selectHasEarlier,
+ selectMessages,
+ selectProviderRetry,
+ trimTranscript,
+ unloadCount,
+ windowTranscript,
} from "../../core/chunks";
import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
import {
- applyDurableMetrics,
- foldMetricsEvent,
- initialMetricsState,
- selectCurrentContextSize,
- selectOrderedTurnMetrics,
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectCurrentContextSize,
+ selectOrderedTurnMetrics,
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
- readonly conversationId: string;
- readonly model?: string;
- readonly transport: ChatTransport;
- readonly historySync: HistorySync;
- readonly metricsSync: MetricsSync;
- readonly cache: ConversationCache;
- /**
- * The workspace this conversation belongs to (its URL slug). Sent on
- * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
- * at creation (default "default"). Absent → omitted (legacy behavior).
- */
- readonly workspaceId?: string;
- /**
- * The chat limit: max loaded chunks before the oldest quarter is unloaded
- * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
- * `DEFAULT_CHAT_LIMIT`.
- */
- readonly chatLimit?: number;
- /**
- * Whether unloading may run RIGHT NOW. The composition root wires this to the
- * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a
- * trim would yank the content under them, so it is DEFERRED until they return
- * to the bottom (the next fold retries). Absent → always allowed.
- */
- readonly canUnload?: () => boolean;
- /**
- * Called when a swallowed error should be surfaced to the user (e.g. a
- * metrics sync failure). Wired by the composition root to the app store's
- * `reportError` → full-screen error modal. Absent → errors are logged only.
- */
- readonly onError?: (context: string, err: unknown) => void;
+ readonly conversationId: string;
+ readonly model?: string;
+ readonly transport: ChatTransport;
+ readonly historySync: HistorySync;
+ readonly metricsSync: MetricsSync;
+ readonly cache: ConversationCache;
+ /**
+ * The workspace this conversation belongs to (its URL slug). Sent on
+ * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
+ * at creation (default "default"). Absent → omitted (legacy behavior).
+ */
+ readonly workspaceId?: string;
+ /**
+ * The chat limit: max loaded chunks before the oldest quarter is unloaded
+ * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
+ * `DEFAULT_CHAT_LIMIT`.
+ */
+ readonly chatLimit?: number;
+ /**
+ * Whether unloading may run RIGHT NOW. The composition root wires this to the
+ * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a
+ * trim would yank the content under them, so it is DEFERRED until they return
+ * to the bottom (the next fold retries). Absent → always allowed.
+ */
+ readonly canUnload?: () => boolean;
+ /**
+ * Called when a swallowed error should be surfaced to the user (e.g. a
+ * metrics sync failure). Wired by the composition root to the app store's
+ * `reportError` → full-screen error modal. Absent → errors are logged only.
+ */
+ readonly onError?: (context: string, err: unknown) => void;
}
export interface ChatStore {
- readonly messages: readonly ChatMessage[];
- readonly chunks: readonly RenderedChunk[];
- readonly turnMetrics: readonly TurnMetricsEntry[];
- /**
- * The conversation's current context size (tokens occupied) — the latest
- * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
- * known yet. Never `0` for the unknown case.
- */
- readonly currentContextSize: number | undefined;
- /**
- * Whether a turn is currently generating server-side — derived from the event
- * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching
- * client: the sender, a second device, or a reconnected client whose in-flight
- * turn was replayed. Drives the composer's "generating…" indicator.
- */
- readonly generating: boolean;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when no
- * retry is pending. Drives the transient yellow "retrying…" warning banner —
- * never persisted (never sent to the model or replayed on reload). Coalesces
- * to the newest attempt + delay; cleared when content resumes or the turn ends.
- */
- readonly providerRetry: TurnProviderRetryEvent | null;
- readonly pendingSync: boolean;
- readonly error: string | null;
- readonly model: string | undefined;
- /**
- * Whether earlier history was unloaded by the chat limit (or never loaded by
- * the fresh-load window) and can be paged back in — drives the
- * "Show earlier messages" affordance.
- */
- readonly hasEarlier: boolean;
- /**
- * Render-key base for thinking collapses: how many thinking chunks are
- * unloaded below the watermark, so the UI's ordinal keys stay stable across
- * a trim (see `TranscriptState.hiddenThinkingCount`).
- */
- readonly thinkingKeyBase: number;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
- send(text: string): void;
- /**
- * Enqueue a steering message onto the conversation's queue (`chat.queue`
- * WS op). While a turn is generating, the message is delivered mid-turn at
- * the next tool-result boundary (a `steering` `AgentEvent` fires + the
- * message-queue surface updates). When no turn is active, the server
- * auto-starts a turn with the message as its opening prompt (equivalent to
- * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
- * pending message until drain; the `steering` event places it in the
- * transcript. `text` must be non-empty (the server 400/errors otherwise).
- */
- queueMessage(text: string): void;
- setModel(model: string): void;
- /**
- * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
- * Lowering it unloads older committed chunks (deferred via the gate while the
- * reader is scrolled up, catching up on the next mutation). Raising it
- * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the
- * fresh-load window (`initialWindowSize` = 75% of the limit) — the same
- * window a fresh `load()` would show — so upping the limit reveals more
- * history instead of leaving a partial view. New deltas + loads use the new
- * limit. The refill awaits, so a caller can preserve scroll over the prepend.
- */
- setChatLimit(limit: number): Promise<void>;
- load(): Promise<void>;
- /**
- * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the
- * "Show earlier messages" action. Local cache first; when the cache doesn't
- * reach far enough back (a server-windowed fresh load), the missing older
- * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache.
- */
- showEarlier(): Promise<void>;
- /**
- * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may
- * have sealed while disconnected — the live `turn-sealed` was missed), then
- * pulls newly-sealed turns from history (+ metrics). If the turn is still
- * running, the server's post-subscribe replay re-asserts `generating`. The
- * app store pairs this with a `chat.subscribe` for the conversation.
- */
- resync(): void;
- dispose(): void;
+ readonly messages: readonly ChatMessage[];
+ readonly chunks: readonly RenderedChunk[];
+ readonly turnMetrics: readonly TurnMetricsEntry[];
+ /**
+ * The conversation's current context size (tokens occupied) — the latest
+ * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
+ * known yet. Never `0` for the unknown case.
+ */
+ readonly currentContextSize: number | undefined;
+ /**
+ * Whether a turn is currently generating server-side — derived from the event
+ * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching
+ * client: the sender, a second device, or a reconnected client whose in-flight
+ * turn was replayed. Drives the composer's "generating…" indicator.
+ */
+ readonly generating: boolean;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when no
+ * retry is pending. Drives the transient yellow "retrying…" warning banner —
+ * never persisted (never sent to the model or replayed on reload). Coalesces
+ * to the newest attempt + delay; cleared when content resumes or the turn ends.
+ */
+ readonly providerRetry: TurnProviderRetryEvent | null;
+ readonly pendingSync: boolean;
+ readonly error: string | null;
+ readonly model: string | undefined;
+ /**
+ * Whether earlier history was unloaded by the chat limit (or never loaded by
+ * the fresh-load window) and can be paged back in — drives the
+ * "Show earlier messages" affordance.
+ */
+ readonly hasEarlier: boolean;
+ /**
+ * Render-key base for thinking collapses: how many thinking chunks are
+ * unloaded below the watermark, so the UI's ordinal keys stay stable across
+ * a trim (see `TranscriptState.hiddenThinkingCount`).
+ */
+ readonly thinkingKeyBase: number;
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ send(text: string): void;
+ /**
+ * Enqueue a steering message onto the conversation's queue (`chat.queue`
+ * WS op). While a turn is generating, the message is delivered mid-turn at
+ * the next tool-result boundary (a `steering` `AgentEvent` fires + the
+ * message-queue surface updates). When no turn is active, the server
+ * auto-starts a turn with the message as its opening prompt (equivalent to
+ * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
+ * pending message until drain; the `steering` event places it in the
+ * transcript. `text` must be non-empty (the server 400/errors otherwise).
+ */
+ queueMessage(text: string): void;
+ setModel(model: string): void;
+ /**
+ * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
+ * Lowering it unloads older committed chunks (deferred via the gate while the
+ * reader is scrolled up, catching up on the next mutation). Raising it
+ * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the
+ * fresh-load window (`initialWindowSize` = 75% of the limit) — the same
+ * window a fresh `load()` would show — so upping the limit reveals more
+ * history instead of leaving a partial view. New deltas + loads use the new
+ * limit. The refill awaits, so a caller can preserve scroll over the prepend.
+ */
+ setChatLimit(limit: number): Promise<void>;
+ load(): Promise<void>;
+ /**
+ * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the
+ * "Show earlier messages" action. Local cache first; when the cache doesn't
+ * reach far enough back (a server-windowed fresh load), the missing older
+ * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache.
+ */
+ showEarlier(): Promise<void>;
+ /**
+ * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may
+ * have sealed while disconnected — the live `turn-sealed` was missed), then
+ * pulls newly-sealed turns from history (+ metrics). If the turn is still
+ * running, the server's post-subscribe replay re-asserts `generating`. The
+ * app store pairs this with a `chat.subscribe` for the conversation.
+ */
+ resync(): void;
+ dispose(): void;
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
- let transcript = $state<TranscriptState>(initialState());
- let metrics = $state<MetricsState>(initialMetricsState());
- let _pendingSync = $state(false);
- let _error = $state<string | null>(null);
- let _model = $state<string | undefined>(deps.model);
- let disposed = false;
+ let transcript = $state<TranscriptState>(initialState());
+ let metrics = $state<MetricsState>(initialMetricsState());
+ let _pendingSync = $state(false);
+ let _error = $state<string | null>(null);
+ let _model = $state<string | undefined>(deps.model);
+ let disposed = false;
- let chatLimit = normalizeChatLimit(deps.chatLimit);
+ let chatLimit = normalizeChatLimit(deps.chatLimit);
- /**
- * Enforce the chat limit after a transcript mutation — unless the injected
- * gate says the reader is scrolled up (then defer; the next mutation retries
- * and `trimTranscript` unloads whole quarters to catch up).
- */
- function maybeTrim(): void {
- if (deps.canUnload !== undefined && !deps.canUnload()) return;
- transcript = trimTranscript(transcript, chatLimit);
- }
+ /**
+ * Enforce the chat limit after a transcript mutation — unless the injected
+ * gate says the reader is scrolled up (then defer; the next mutation retries
+ * and `trimTranscript` unloads whole quarters to catch up).
+ */
+ function maybeTrim(): void {
+ if (deps.canUnload !== undefined && !deps.canUnload()) return;
+ transcript = trimTranscript(transcript, chatLimit);
+ }
- /**
- * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when
- * given AND the cache is empty (a truly fresh browser), windows the fetch to
- * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship
- * whole. It is deliberately NOT applied to a warm-cache tail: windowing a
- * tail that grew past N while we were away would leave a silent seq GAP
- * between the cache and the fetched window.
- */
- async function syncTail(coldLimit?: number): Promise<void> {
- if (disposed || _pendingSync) return;
- _pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
- const res = await deps.historySync(deps.conversationId, since, window);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- maybeTrim();
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
- }
- }
+ /**
+ * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when
+ * given AND the cache is empty (a truly fresh browser), windows the fetch to
+ * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship
+ * whole. It is deliberately NOT applied to a warm-cache tail: windowing a
+ * tail that grew past N while we were away would leave a silent seq GAP
+ * between the cache and the fetched window.
+ */
+ async function syncTail(coldLimit?: number): Promise<void> {
+ if (disposed || _pendingSync) return;
+ _pendingSync = true;
+ try {
+ const since = await deps.cache.sinceSeq(deps.conversationId);
+ const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
+ const res = await deps.historySync(deps.conversationId, since, window);
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ transcript = applyHistory(transcript, merged);
+ maybeTrim();
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ } finally {
+ _pendingSync = false;
+ }
+ }
- async function syncMetrics(): Promise<void> {
- if (disposed) return;
- try {
- const res = await deps.metricsSync(deps.conversationId);
- metrics = applyDurableMetrics(metrics, res.turns);
- } catch (err) {
- // Metrics fetch failure must not block history sync or throw;
- // live-folded metrics remain intact. Surface via onError (modal).
- console.error("[syncMetrics] failed:", err);
- deps.onError?.("Failed to sync conversation metrics", err);
- }
- }
+ async function syncMetrics(): Promise<void> {
+ if (disposed) return;
+ try {
+ const res = await deps.metricsSync(deps.conversationId);
+ metrics = applyDurableMetrics(metrics, res.turns);
+ } catch (err) {
+ // Metrics fetch failure must not block history sync or throw;
+ // live-folded metrics remain intact. Surface via onError (modal).
+ console.error("[syncMetrics] failed:", err);
+ deps.onError?.("Failed to sync conversation metrics", err);
+ }
+ }
- /**
- * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a
- * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow,
- * persisting it so the next read is local. Returns every locally-known
- * chunk older than `oldest` (the caller — `restoreEarlier` — takes the
- * newest `count` of them). Shared by `showEarlier` and the raise-refill.
- */
- async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
- let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
- const oldestKnown = earlier[0]?.seq ?? oldest;
- if (earlier.length < want && oldestKnown > 1) {
- const res = await deps.historySync(deps.conversationId, 0, {
- beforeSeq: oldestKnown,
- limit: want - earlier.length,
- });
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- earlier = merged.filter((c) => c.seq < oldest);
- }
- return earlier;
- }
+ /**
+ * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a
+ * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow,
+ * persisting it so the next read is local. Returns every locally-known
+ * chunk older than `oldest` (the caller — `restoreEarlier` — takes the
+ * newest `count` of them). Shared by `showEarlier` and the raise-refill.
+ */
+ async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
+ let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
+ const oldestKnown = earlier[0]?.seq ?? oldest;
+ if (earlier.length < want && oldestKnown > 1) {
+ const res = await deps.historySync(deps.conversationId, 0, {
+ beforeSeq: oldestKnown,
+ limit: want - earlier.length,
+ });
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ earlier = merged.filter((c) => c.seq < oldest);
+ }
+ return earlier;
+ }
- /**
- * Refill toward the fresh-load window after a limit RAISE: pull older
- * history (cache first, then server) so the loaded set grows to match what a
- * fresh `load()` would show at the new limit. No-op when already at the
- * origin (seq 1) or already within the window. `restoreEarlier` re-derives
- * the window start at apply time, so a delta landing during the await can't
- * corrupt the merge. NOT gated (refilling prepends above the viewport; the
- * caller preserves scroll position).
- */
- async function refill(): Promise<void> {
- if (disposed) return;
- const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
- const want = initialWindowSize(chatLimit) - transcript.committed.length;
- if (want <= 0) return;
- try {
- const earlier = await backfillOlder(oldest, want);
- if (earlier.length === 0) return;
- transcript = restoreEarlier(transcript, earlier, want);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- }
- }
+ /**
+ * Refill toward the fresh-load window after a limit RAISE: pull older
+ * history (cache first, then server) so the loaded set grows to match what a
+ * fresh `load()` would show at the new limit. No-op when already at the
+ * origin (seq 1) or already within the window. `restoreEarlier` re-derives
+ * the window start at apply time, so a delta landing during the await can't
+ * corrupt the merge. NOT gated (refilling prepends above the viewport; the
+ * caller preserves scroll position).
+ */
+ async function refill(): Promise<void> {
+ if (disposed) return;
+ const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
+ if (oldest <= 1) return;
+ const want = initialWindowSize(chatLimit) - transcript.committed.length;
+ if (want <= 0) return;
+ try {
+ const earlier = await backfillOlder(oldest, want);
+ if (earlier.length === 0) return;
+ transcript = restoreEarlier(transcript, earlier, want);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ }
- return {
- get messages(): readonly ChatMessage[] {
- return selectMessages(transcript);
- },
- get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
- },
- get turnMetrics(): readonly TurnMetricsEntry[] {
- return selectOrderedTurnMetrics(metrics);
- },
- get currentContextSize(): number | undefined {
- return selectCurrentContextSize(metrics);
- },
- get generating(): boolean {
- return selectGenerating(transcript);
- },
- get providerRetry(): TurnProviderRetryEvent | null {
- return selectProviderRetry(transcript);
- },
- get pendingSync(): boolean {
- return _pendingSync;
- },
- get error(): string | null {
- return _error;
- },
- get model(): string | undefined {
- return _model;
- },
- get hasEarlier(): boolean {
- return selectHasEarlier(transcript);
- },
- get thinkingKeyBase(): number {
- return transcript.hiddenThinkingCount;
- },
+ return {
+ get messages(): readonly ChatMessage[] {
+ return selectMessages(transcript);
+ },
+ get chunks(): readonly RenderedChunk[] {
+ return selectChunks(transcript);
+ },
+ get turnMetrics(): readonly TurnMetricsEntry[] {
+ return selectOrderedTurnMetrics(metrics);
+ },
+ get currentContextSize(): number | undefined {
+ return selectCurrentContextSize(metrics);
+ },
+ get generating(): boolean {
+ return selectGenerating(transcript);
+ },
+ get providerRetry(): TurnProviderRetryEvent | null {
+ return selectProviderRetry(transcript);
+ },
+ get pendingSync(): boolean {
+ return _pendingSync;
+ },
+ get error(): string | null {
+ return _error;
+ },
+ get model(): string | undefined {
+ return _model;
+ },
+ get hasEarlier(): boolean {
+ return selectHasEarlier(transcript);
+ },
+ get thinkingKeyBase(): number {
+ return transcript.hiddenThinkingCount;
+ },
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
- if (msg.type === "chat.error") {
- if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
- return;
- }
- _error = msg.message;
- return;
- }
- if (msg.event.conversationId !== deps.conversationId) {
- return;
- }
- transcript = foldEvent(transcript, msg.event);
- metrics = foldMetricsEvent(metrics, msg.event);
- maybeTrim();
- if (transcript.sealedTurnId !== null) {
- void syncTail();
- void syncMetrics();
- }
- },
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ if (msg.type === "chat.error") {
+ if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
+ return;
+ }
+ _error = msg.message;
+ return;
+ }
+ if (msg.event.conversationId !== deps.conversationId) {
+ return;
+ }
+ transcript = foldEvent(transcript, msg.event);
+ metrics = foldMetricsEvent(metrics, msg.event);
+ maybeTrim();
+ if (transcript.sealedTurnId !== null) {
+ void syncTail();
+ void syncMetrics();
+ }
+ },
- send(text: string): void {
- transcript = appendUserMessage(transcript, text);
- maybeTrim();
- const msg: ChatSendMessage = {
- type: "chat.send",
- conversationId: deps.conversationId,
- message: text,
- ...(_model !== undefined ? { model: _model } : {}),
- ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
- };
- deps.transport.send(msg);
- },
+ send(text: string): void {
+ transcript = appendUserMessage(transcript, text);
+ maybeTrim();
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: deps.conversationId,
+ message: text,
+ ...(_model !== undefined ? { model: _model } : {}),
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ };
+ deps.transport.send(msg);
+ },
- queueMessage(text: string): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- const msg: ChatQueueMessage = {
- type: "chat.queue",
- conversationId: deps.conversationId,
- text: trimmed,
- ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
- };
- deps.transport.send(msg);
- },
+ queueMessage(text: string): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ const msg: ChatQueueMessage = {
+ type: "chat.queue",
+ conversationId: deps.conversationId,
+ text: trimmed,
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ };
+ deps.transport.send(msg);
+ },
- setModel(model: string): void {
- _model = model;
- },
+ setModel(model: string): void {
+ _model = model;
+ },
- async setChatLimit(limit: number): Promise<void> {
- const prev = chatLimit;
- chatLimit = normalizeChatLimit(limit);
- if (chatLimit < prev) {
- maybeTrim();
- } else if (chatLimit > prev) {
- await refill();
- }
- },
+ async setChatLimit(limit: number): Promise<void> {
+ const prev = chatLimit;
+ chatLimit = normalizeChatLimit(limit);
+ if (chatLimit < prev) {
+ maybeTrim();
+ } else if (chatLimit > prev) {
+ await refill();
+ }
+ },
- async load(): Promise<void> {
- // Fresh load shows only the newest 75% of the limit — headroom before the
- // first trim. A warm cache is windowed locally (synchronously with its
- // apply — no render in between); a COLD cache passes the window to the
- // server instead (CR-5 `?limit=`), so a huge conversation never ships
- // whole. The post-sync window re-asserts the cap either way.
- const windowSize = initialWindowSize(chatLimit);
- const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
- transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
- }
- await syncTail(windowSize);
- transcript = windowTranscript(transcript, windowSize);
- await syncMetrics();
- },
+ async load(): Promise<void> {
+ // Fresh load shows only the newest 75% of the limit — headroom before the
+ // first trim. A warm cache is windowed locally (synchronously with its
+ // apply — no render in between); a COLD cache passes the window to the
+ // server instead (CR-5 `?limit=`), so a huge conversation never ships
+ // whole. The post-sync window re-asserts the cap either way.
+ const windowSize = initialWindowSize(chatLimit);
+ const cached = await deps.cache.load(deps.conversationId);
+ if (cached.length > 0) {
+ transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
+ }
+ await syncTail(windowSize);
+ transcript = windowTranscript(transcript, windowSize);
+ await syncMetrics();
+ },
- async showEarlier(): Promise<void> {
- if (disposed) return;
- const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
- const want = unloadCount(chatLimit);
- try {
- const earlier = await backfillOlder(oldest, want);
- transcript = restoreEarlier(transcript, earlier, want);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- }
- },
+ async showEarlier(): Promise<void> {
+ if (disposed) return;
+ const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
+ if (oldest <= 1) return;
+ const want = unloadCount(chatLimit);
+ try {
+ const earlier = await backfillOlder(oldest, want);
+ transcript = restoreEarlier(transcript, earlier, want);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ },
- resync(): void {
- if (disposed) return;
- // A turn may have sealed while we were disconnected (missed `turn-sealed`):
- // clear the now-stale spinner BEFORE re-subscribing, so a finished turn
- // doesn't spin forever. A still-running turn's replay re-asserts it.
- transcript = clearGenerating(transcript);
- void syncTail();
- void syncMetrics();
- },
+ resync(): void {
+ if (disposed) return;
+ // A turn may have sealed while we were disconnected (missed `turn-sealed`):
+ // clear the now-stale spinner BEFORE re-subscribing, so a finished turn
+ // doesn't spin forever. A still-running turn's replay re-asserts it.
+ transcript = clearGenerating(transcript);
+ void syncTail();
+ void syncMetrics();
+ },
- dispose(): void {
- disposed = true;
- },
- };
+ dispose(): void {
+ disposed = true;
+ },
+ };
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index c1d62a6..c052d1b 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -2,1548 +2,1548 @@ import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
import {
- createFakeCache,
- createFakeHistorySync,
- createFakeMetricsSync,
- createFakeTransport,
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
} from "./test-helpers";
const CONV_ID = "test-conv-1";
function makeStoredChunk(seq: number, role: "user" | "assistant" = "assistant"): StoredChunk {
- return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
+ return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
}
function deltaEvent(event: AgentEvent): import("@dispatch/transport-contract").ChatDeltaMessage {
- return { type: "chat.delta", event };
+ return { type: "chat.delta", event };
}
function errorMessage(message: string): import("@dispatch/transport-contract").ChatErrorMessage {
- return { type: "chat.error", message };
+ return { type: "chat.error", message };
}
describe("createChatStore", () => {
- it("folding a chat.delta updates messages", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
- );
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
- );
-
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("assistant");
- expect(store.messages[0]?.chunks).toHaveLength(1);
- expect(store.messages[0]?.chunks[0]?.type).toBe("text");
- expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
- "Hello world",
- );
-
- store.dispose();
- });
-
- it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Set up what the history sync will return
- historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
- );
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait for the async sync to complete
- await vi.waitFor(() => {
- expect(historySync.calls).toHaveLength(1);
- });
-
- expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
-
- // Cache should have the committed chunks
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(2);
-
- // Messages should include both provisional and committed
- expect(store.messages.length).toBeGreaterThanOrEqual(1);
-
- store.dispose();
- });
-
- it("send posts a chat.send with conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello server");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.type).toBe("chat.send");
- expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
- expect(transport.sent[0]?.message).toBe("Hello server");
- expect(transport.sent[0]).not.toHaveProperty("model");
-
- store.dispose();
- });
-
- it("send posts a chat.send with model when set", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.dispose();
- });
-
- describe("queueMessage (chat.queue — steering)", () => {
- it("posts a chat.queue with conversationId + text", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage("steer left");
-
- expect(transport.sent).toHaveLength(0); // chat.send stays empty
- expect(transport.sentQueue).toHaveLength(1);
- expect(transport.sentQueue[0]?.type).toBe("chat.queue");
- expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
- expect(transport.sentQueue[0]?.text).toBe("steer left");
-
- store.dispose();
- });
-
- it("trims whitespace before sending", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage(" padded ");
-
- expect(transport.sentQueue[0]?.text).toBe("padded");
-
- store.dispose();
- });
-
- it("does not send for empty/whitespace-only text", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage(" ");
- store.queueMessage("");
-
- expect(transport.sentQueue).toHaveLength(0);
-
- store.dispose();
- });
-
- it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage("queued steering message");
-
- expect(store.chunks).toHaveLength(0); // no transcript echo
-
- store.dispose();
- });
- });
-
- it("chat.error sets error", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.error).toBeNull();
-
- store.handleDelta(errorMessage("Something broke"));
-
- expect(store.error).toBe("Something broke");
-
- store.dispose();
- });
-
- it("load hydrates from cache then syncs the tail", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- // Pre-populate cache
- await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
-
- // History sync returns new chunks
- historySync.returnChunks = [makeStoredChunk(3, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- // Should have synced
- expect(historySync.calls).toHaveLength(1);
- expect(historySync.calls[0]?.sinceSeq).toBe(2);
-
- // Messages should include all chunks
- expect(store.messages.length).toBeGreaterThanOrEqual(2);
-
- store.dispose();
- });
-
- it("load with empty cache still syncs", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- historySync.returnChunks = [makeStoredChunk(1, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- expect(historySync.calls).toHaveLength(1);
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
-
- store.dispose();
- });
-
- it("error is cleared on successful sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // First, set an error
- store.handleDelta(errorMessage("fail"));
- expect(store.error).toBe("fail");
-
- // Now trigger a successful sync via turn-sealed
- historySync.returnChunks = [makeStoredChunk(1)];
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(store.error).toBeNull();
- });
-
- store.dispose();
- });
-
- it("dispose prevents further syncs", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.dispose();
-
- // Trigger a turn-sealed after dispose
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait a tick to let any async work settle
- await new Promise((r) => setTimeout(r, 10));
-
- // No sync should have happened
- expect(historySync.calls).toHaveLength(0);
-
- store.dispose();
- });
-
- it("overlapping syncs are guarded", async () => {
- const transport = createFakeTransport();
- const _historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- // Make the first sync slow
- let resolveFirstSync: (() => void) | undefined;
- const firstSyncPromise = new Promise<void>((resolve) => {
- resolveFirstSync = resolve;
- });
-
- let callCount = 0;
- const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
- callCount++;
- if (callCount === 1) {
- await firstSyncPromise;
- }
- return { chunks: [], latestSeq: sinceSeq };
- };
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: slowHistorySync,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Trigger first sync
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait a tick so the first sync starts
- await new Promise((r) => setTimeout(r, 0));
-
- // Trigger second sync while first is pending
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
-
- // Only one call should have been made (second was guarded)
- expect(callCount).toBe(1);
-
- // Release the first sync
- resolveFirstSync?.();
- await new Promise((r) => setTimeout(r, 10));
-
- store.dispose();
- });
-
- it("handles tool-call and tool-result chunks", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp/test.txt" },
- stepId: "t1#0" as StepId,
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "tool-result",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents",
- isError: false,
- stepId: "t1#0" as StepId,
- }),
- );
-
- expect(store.chunks).toHaveLength(2);
- expect(store.chunks[0]?.chunk.type).toBe("tool-call");
- expect(store.chunks[1]?.chunk.type).toBe("tool-result");
-
- store.dispose();
- });
-
- it("setModel changes the model used by the next send", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("First");
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.setModel("anthropic/claude-3");
- store.send("Second");
- expect(transport.sent[1]?.model).toBe("anthropic/claude-3");
-
- store.dispose();
- });
-
- it("setModel from undefined to a model", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("First");
- expect(transport.sent[0]).not.toHaveProperty("model");
-
- store.setModel("openai/gpt-4o");
- store.send("Second");
- expect(transport.sent[1]?.model).toBe("openai/gpt-4o");
-
- store.dispose();
- });
-
- it("handleDelta ignores a chat.delta for a different conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(
- deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }),
- );
- store.handleDelta(
- deltaEvent({
- type: "text-delta",
- conversationId: "other-conv",
- turnId: "t1",
- delta: "Should be ignored",
- }),
- );
-
- expect(store.messages).toHaveLength(0);
-
- store.dispose();
- });
-
- it("handleDelta ignores a chat.error for a different conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" });
-
- expect(store.error).toBeNull();
-
- store.dispose();
- });
-
- it("send optimistically shows the user message immediately", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("hi");
-
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("user");
- expect(store.messages[0]?.chunks).toHaveLength(1);
- expect(store.messages[0]?.chunks[0]?.type).toBe("text");
- expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi");
-
- store.dispose();
- });
-
- it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- historySync.returnChunks = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
- ];
-
- store.send("hi");
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("user");
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(store.messages.length).toBe(2);
- });
-
- expect(store.messages[0]?.role).toBe("user");
- expect(store.messages[1]?.role).toBe("assistant");
-
- store.dispose();
- });
-
- it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.turnMetrics).toHaveLength(0);
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "step-complete",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 200,
- genTotalMs: 800,
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- durationMs: 1200,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- const entry = store.turnMetrics[0];
- expect(entry?.turnId).toBe("t1");
- expect(entry?.steps).toHaveLength(1);
- expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
- expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
- expect(entry?.steps[0]?.genTotalMs).toBe(800);
- expect(entry?.total).not.toBeNull();
- expect(entry?.total?.usage.inputTokens).toBe(100);
- expect(entry?.total?.usage.outputTokens).toBe(50);
- expect(entry?.total?.durationMs).toBe(1200);
-
- store.dispose();
- });
-
- it("turnMetrics entry has total: null before done (progressive turn)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "step-complete",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 200,
- genTotalMs: 800,
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- const entry = store.turnMetrics[0];
- expect(entry?.turnId).toBe("t1");
- expect(entry?.steps).toHaveLength(1);
- expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
- expect(entry?.total).toBeNull();
-
- store.dispose();
- });
-
- it("metricsSync durable result overrides live by turnId", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Live fold gives some metrics
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
-
- // Durable sync returns different numbers for the same turnId
- metricsSync.returnTurns = [
- {
- turnId: "t1",
- usage: { inputTokens: 200, outputTokens: 80 },
- durationMs: 500,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 200, outputTokens: 80 },
- genTotalMs: 400,
- },
- ],
- },
- ];
-
- // Trigger metrics sync via turn-sealed
- historySync.returnChunks = [];
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- // Durable should now override live (syncMetrics is async, wait for it)
- await vi.waitFor(() => {
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
- });
-
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
-
- store.dispose();
- });
-
- it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Live fold some metrics
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
-
- // Make the metrics sync reject
- metricsSync.nextError = "metrics endpoint unavailable";
-
- historySync.returnChunks = [];
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- // Live metrics should still be intact
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
-
- // No error should have been thrown to the store
- expect(store.error).toBeNull();
-
- store.dispose();
- });
-
- it("load calls metricsSync after history sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- metricsSync.returnTurns = [
- {
- turnId: "t1",
- usage: { inputTokens: 300, outputTokens: 100 },
- durationMs: 900,
- steps: [],
- },
- ];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- expect(historySync.calls).toHaveLength(1);
- expect(metricsSync.calls).toHaveLength(1);
- expect(metricsSync.calls[0]).toBe(CONV_ID);
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
-
- store.dispose();
- });
-
- it("generating reflects the turn lifecycle (idle → running → idle)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.generating).toBe(false);
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- expect(store.generating).toBe(true);
-
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }),
- );
- expect(store.generating).toBe(true);
-
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- expect(store.generating).toBe(false);
-
- store.dispose();
- });
-
- it("generating lights up for a watcher whose turn was replayed (no send first)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // A late-joiner receives the in-flight turn replayed from turn-start.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }),
- );
- expect(store.generating).toBe(true);
- expect(transport.sent).toHaveLength(0); // it never sent — it's just watching
-
- store.dispose();
- });
-
- it("resync clears a stale generating flag and re-syncs history + metrics", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was
- // missed, so generating is stuck true.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- expect(store.generating).toBe(true);
-
- // The turn actually sealed while we were gone — history now has the chunks.
- historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
-
- store.resync();
-
- // Generating is cleared synchronously (a finished turn must not spin forever).
- expect(store.generating).toBe(false);
-
- await vi.waitFor(() => {
- expect(historySync.calls).toHaveLength(1);
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- store.dispose();
- });
-
- it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // Commit exactly 100 chunks via a sealed turn (at the limit — no trim).
- const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1));
- historySync.returnChunks = hundred;
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(100);
- });
- expect(store.hasEarlier).toBe(false);
-
- // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t2",
- toolCallId: "tc1",
- toolName: "probe",
- input: {},
- stepId: "t2#0" as StepId,
- }),
- );
-
- expect(store.chunks).toHaveLength(76);
- expect(store.chunks[0]?.seq).toBe(26);
- expect(store.hasEarlier).toBe(true);
-
- store.dispose();
- });
-
- it("chat limit: unloading is deferred while the gate is closed, then catches up", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- let atBottom = false; // reader scrolled up
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 10,
- canUnload: () => atBottom,
- });
-
- // 15 live tool-calls: over the limit, but the gate defers every trim.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- for (let i = 0; i < 15; i++) {
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: `tc${i}`,
- toolName: "probe",
- input: {},
- stepId: `t1#${i}` as StepId,
- }),
- );
- }
- expect(store.chunks).toHaveLength(15);
-
- // Reader returns to the bottom — the deferred trim now catches up.
- // With no committed chunks, it drops the oldest provisional chunks
- // (the in-flight turn) to stay within the limit.
- atBottom = true;
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc15",
- toolName: "probe",
- input: {},
- stepId: "t1#15" as StepId,
- }),
- );
- // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
- expect(store.chunks).toHaveLength(10);
-
- store.dispose();
- });
-
- it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- let atBottom = false;
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- canUnload: () => atBottom,
- });
-
- // Seal a turn committing 130 chunks while the reader is scrolled up: no trim.
- historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(130);
- });
-
- // Back at the bottom: the next fold trims whole quarters down to ≤ 100.
- atBottom = true;
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk).
- expect(store.chunks).toHaveLength(80);
- expect(store.chunks[0]?.seq).toBe(51);
-
- store.dispose();
- });
-
- it("chat limit: load windows a long cached conversation to 75% of the limit", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- // floor(100 × 0.75) = 75 newest chunks: seqs 426..500.
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(426);
- expect(store.hasEarlier).toBe(true);
- // The tail sync still used the cache's real cursor (not the window's edge).
- expect(historySync.calls[0]?.sinceSeq).toBe(500);
-
- store.dispose();
- });
-
- it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // The server holds 500 chunks; the windowed fetch returns the newest 75.
- historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`).
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
- expect(historySync.calls[0]?.window).toEqual({ limit: 75 });
-
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(426);
- // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) —
- // no local watermark was ever set.
- expect(store.hasEarlier).toBe(true);
- // Only the window was shipped + cached (the point of CR-5).
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(75);
-
- store.dispose();
- });
-
- it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]);
- historySync.returnChunks = [makeStoredChunk(3)];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- expect(historySync.calls[0]?.sinceSeq).toBe(2);
- expect(historySync.calls[0]?.window).toBeUndefined();
-
- store.dispose();
- });
-
- it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load(); // server-windowed: loaded + cached = 426..500
- expect(store.chunks[0]?.seq).toBe(426);
-
- await store.showEarlier();
-
- // Nothing below 426 was cached → fetched the missing run from the server.
- const backfill = historySync.calls[1];
- expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 });
- expect(store.chunks).toHaveLength(100);
- expect(store.chunks[0]?.seq).toBe(401);
- expect(store.hasEarlier).toBe(true);
- // The backfilled run is persisted: the NEXT page-in is cache-local.
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(100);
-
- store.dispose();
- });
-
- it("chat limit: showEarlier pages a quarter back in from the cache", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
- expect(store.chunks[0]?.seq).toBe(426);
-
- await store.showEarlier(); // +ceil(100/4) = 25 older chunks
- expect(store.chunks).toHaveLength(100);
- expect(store.chunks[0]?.seq).toBe(401);
- expect(store.hasEarlier).toBe(true);
- // The cache reached deep enough — no server backfill was needed.
- expect(historySync.calls).toHaveLength(1);
-
- store.dispose();
- });
-
- it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load(); // window 75: hidden 1..5
- expect(store.chunks).toHaveLength(75);
- expect(store.hasEarlier).toBe(true);
-
- await store.showEarlier(); // restores all 5 → nothing left below
- expect(store.chunks).toHaveLength(80);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(store.hasEarlier).toBe(false);
-
- store.dispose();
- });
-
- it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
- expect(store.chunks[0]?.seq).toBe(426);
-
- // A sealed turn triggers syncTail, whose cache.commit returns the FULL
- // merged cache (seqs 1..501) — the watermark must keep 1..425 out.
- historySync.returnChunks = [makeStoredChunk(501)];
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" }));
-
- await vi.waitFor(() => {
- expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501);
- });
- expect(store.chunks[0]?.seq).toBe(426);
- expect(store.chunks).toHaveLength(76);
-
- store.dispose();
- });
-
- it("setChatLimit: lowering the limit trims older committed chunks live", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // Load 80 committed chunks (under the limit — no trim yet).
- historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(80);
- });
-
- // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs
- // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain.
- await store.setChatLimit(10);
- expect(store.chunks).toHaveLength(8);
- expect(store.chunks[0]?.seq).toBe(73);
- expect(store.hasEarlier).toBe(true);
-
- store.dispose();
- });
-
- it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200.
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
- );
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load();
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(126);
- expect(store.hasEarlier).toBe(true);
-
- // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks
- // (seqs 51..125) from the cache. No server backfill (cache is deep enough).
- await store.setChatLimit(200);
- expect(historySync.calls).toHaveLength(1); // the load-time tail sync only
- expect(store.chunks).toHaveLength(150);
- expect(store.chunks[0]?.seq).toBe(51);
- expect(store.hasEarlier).toBe(true); // 51 > 1
-
- store.dispose();
- });
-
- it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
- historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load();
- expect(store.chunks[0]?.seq).toBe(126);
-
- // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill
- // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75).
- await store.setChatLimit(200);
- const backfill = historySync.calls[1];
- expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 });
- expect(store.chunks).toHaveLength(150);
- expect(store.chunks[0]?.seq).toBe(51);
-
- store.dispose();
- });
-
- it("setChatLimit: raising refills all available older history (down to the origin)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101).
- historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(76);
- });
- expect(store.chunks[0]?.seq).toBe(26);
- expect(store.hasEarlier).toBe(true);
-
- // Raise to 500 → window 375 → want 299 older. The cache holds only
- // seqs 1..25 below the window (no more server-side) → restore all 25 →
- // 101 loaded, reaching the origin.
- await store.setChatLimit(500);
- expect(store.chunks).toHaveLength(101);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(store.hasEarlier).toBe(false);
-
- store.dispose();
- });
-
- it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
- );
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load(); // only 50 chunks → all loaded, window starts at seq 1
- expect(store.chunks).toHaveLength(50);
- expect(store.hasEarlier).toBe(false);
- const callsAfterLoad = historySync.calls.length;
-
- await store.setChatLimit(500); // raise → refill no-ops (oldest = 1)
- expect(store.chunks).toHaveLength(50);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill
-
- store.dispose();
- });
-
- it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(50);
- });
-
- // NaN normalizes to the default (256). prev was 100 → raise → refill,
- // but the loaded window already starts at seq 1 (origin) → no-op.
- await store.setChatLimit(Number.NaN);
- expect(store.chunks).toHaveLength(50);
-
- store.dispose();
- });
-
- it("resync is a no-op after dispose", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.dispose();
- store.resync();
-
- await new Promise((r) => setTimeout(r, 10));
- expect(historySync.calls).toHaveLength(0);
- expect(metricsSync.calls).toHaveLength(0);
- });
+ it("folding a chat.delta updates messages", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
+ );
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("assistant");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
+ "Hello world",
+ );
+
+ store.dispose();
+ });
+
+ it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Set up what the history sync will return
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait for the async sync to complete
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ });
+
+ expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ // Cache should have the committed chunks
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(2);
+
+ // Messages should include both provisional and committed
+ expect(store.messages.length).toBeGreaterThanOrEqual(1);
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("Hello server");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.type).toBe("chat.send");
+ expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sent[0]?.message).toBe("Hello server");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with model when set", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ model: "openai/gpt-4",
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("Hello");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.dispose();
+ });
+
+ describe("queueMessage (chat.queue — steering)", () => {
+ it("posts a chat.queue with conversationId + text", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage("steer left");
+
+ expect(transport.sent).toHaveLength(0); // chat.send stays empty
+ expect(transport.sentQueue).toHaveLength(1);
+ expect(transport.sentQueue[0]?.type).toBe("chat.queue");
+ expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sentQueue[0]?.text).toBe("steer left");
+
+ store.dispose();
+ });
+
+ it("trims whitespace before sending", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage(" padded ");
+
+ expect(transport.sentQueue[0]?.text).toBe("padded");
+
+ store.dispose();
+ });
+
+ it("does not send for empty/whitespace-only text", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage(" ");
+ store.queueMessage("");
+
+ expect(transport.sentQueue).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage("queued steering message");
+
+ expect(store.chunks).toHaveLength(0); // no transcript echo
+
+ store.dispose();
+ });
+ });
+
+ it("chat.error sets error", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.error).toBeNull();
+
+ store.handleDelta(errorMessage("Something broke"));
+
+ expect(store.error).toBe("Something broke");
+
+ store.dispose();
+ });
+
+ it("load hydrates from cache then syncs the tail", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ // Pre-populate cache
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
+
+ // History sync returns new chunks
+ historySync.returnChunks = [makeStoredChunk(3, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ // Should have synced
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+
+ // Messages should include all chunks
+ expect(store.messages.length).toBeGreaterThanOrEqual(2);
+
+ store.dispose();
+ });
+
+ it("load with empty cache still syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ historySync.returnChunks = [makeStoredChunk(1, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ store.dispose();
+ });
+
+ it("error is cleared on successful sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // First, set an error
+ store.handleDelta(errorMessage("fail"));
+ expect(store.error).toBe("fail");
+
+ // Now trigger a successful sync via turn-sealed
+ historySync.returnChunks = [makeStoredChunk(1)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.error).toBeNull();
+ });
+
+ store.dispose();
+ });
+
+ it("dispose prevents further syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.dispose();
+
+ // Trigger a turn-sealed after dispose
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick to let any async work settle
+ await new Promise((r) => setTimeout(r, 10));
+
+ // No sync should have happened
+ expect(historySync.calls).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("overlapping syncs are guarded", async () => {
+ const transport = createFakeTransport();
+ const _historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ // Make the first sync slow
+ let resolveFirstSync: (() => void) | undefined;
+ const firstSyncPromise = new Promise<void>((resolve) => {
+ resolveFirstSync = resolve;
+ });
+
+ let callCount = 0;
+ const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
+ callCount++;
+ if (callCount === 1) {
+ await firstSyncPromise;
+ }
+ return { chunks: [], latestSeq: sinceSeq };
+ };
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: slowHistorySync,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Trigger first sync
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick so the first sync starts
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Trigger second sync while first is pending
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
+
+ // Only one call should have been made (second was guarded)
+ expect(callCount).toBe(1);
+
+ // Release the first sync
+ resolveFirstSync?.();
+ await new Promise((r) => setTimeout(r, 10));
+
+ store.dispose();
+ });
+
+ it("handles tool-call and tool-result chunks", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ stepId: "t1#0" as StepId,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(2);
+ expect(store.chunks[0]?.chunk.type).toBe("tool-call");
+ expect(store.chunks[1]?.chunk.type).toBe("tool-result");
+
+ store.dispose();
+ });
+
+ it("setModel changes the model used by the next send", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ model: "openai/gpt-4",
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("First");
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.setModel("anthropic/claude-3");
+ store.send("Second");
+ expect(transport.sent[1]?.model).toBe("anthropic/claude-3");
+
+ store.dispose();
+ });
+
+ it("setModel from undefined to a model", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("First");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.setModel("openai/gpt-4o");
+ store.send("Second");
+ expect(transport.sent[1]?.model).toBe("openai/gpt-4o");
+
+ store.dispose();
+ });
+
+ it("handleDelta ignores a chat.delta for a different conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(
+ deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "text-delta",
+ conversationId: "other-conv",
+ turnId: "t1",
+ delta: "Should be ignored",
+ }),
+ );
+
+ expect(store.messages).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("handleDelta ignores a chat.error for a different conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" });
+
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("send optimistically shows the user message immediately", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("hi");
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi");
+
+ store.dispose();
+ });
+
+ it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ historySync.returnChunks = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
+ ];
+
+ store.send("hi");
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.messages.length).toBe(2);
+ });
+
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[1]?.role).toBe("assistant");
+
+ store.dispose();
+ });
+
+ it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.turnMetrics).toHaveLength(0);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ durationMs: 1200,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
+ expect(entry?.steps[0]?.genTotalMs).toBe(800);
+ expect(entry?.total).not.toBeNull();
+ expect(entry?.total?.usage.inputTokens).toBe(100);
+ expect(entry?.total?.usage.outputTokens).toBe(50);
+ expect(entry?.total?.durationMs).toBe(1200);
+
+ store.dispose();
+ });
+
+ it("turnMetrics entry has total: null before done (progressive turn)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.total).toBeNull();
+
+ store.dispose();
+ });
+
+ it("metricsSync durable result overrides live by turnId", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold gives some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // Durable sync returns different numbers for the same turnId
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 200, outputTokens: 80 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 200, outputTokens: 80 },
+ genTotalMs: 400,
+ },
+ ],
+ },
+ ];
+
+ // Trigger metrics sync via turn-sealed
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Durable should now override live (syncMetrics is async, wait for it)
+ await vi.waitFor(() => {
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
+ });
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
+
+ store.dispose();
+ });
+
+ it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+
+ // Make the metrics sync reject
+ metricsSync.nextError = "metrics endpoint unavailable";
+
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Live metrics should still be intact
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // No error should have been thrown to the store
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("load calls metricsSync after history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 300, outputTokens: 100 },
+ durationMs: 900,
+ steps: [],
+ },
+ ];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ expect(metricsSync.calls[0]).toBe(CONV_ID);
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
+
+ store.dispose();
+ });
+
+ it("generating reflects the turn lifecycle (idle → running → idle)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.generating).toBe(false);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.generating).toBe(true);
+
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }),
+ );
+ expect(store.generating).toBe(true);
+
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ expect(store.generating).toBe(false);
+
+ store.dispose();
+ });
+
+ it("generating lights up for a watcher whose turn was replayed (no send first)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // A late-joiner receives the in-flight turn replayed from turn-start.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }),
+ );
+ expect(store.generating).toBe(true);
+ expect(transport.sent).toHaveLength(0); // it never sent — it's just watching
+
+ store.dispose();
+ });
+
+ it("resync clears a stale generating flag and re-syncs history + metrics", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was
+ // missed, so generating is stuck true.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.generating).toBe(true);
+
+ // The turn actually sealed while we were gone — history now has the chunks.
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.resync();
+
+ // Generating is cleared synchronously (a finished turn must not spin forever).
+ expect(store.generating).toBe(false);
+
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ store.dispose();
+ });
+
+ it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // Commit exactly 100 chunks via a sealed turn (at the limit — no trim).
+ const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1));
+ historySync.returnChunks = hundred;
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(100);
+ });
+ expect(store.hasEarlier).toBe(false);
+
+ // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t2",
+ toolCallId: "tc1",
+ toolName: "probe",
+ input: {},
+ stepId: "t2#0" as StepId,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(76);
+ expect(store.chunks[0]?.seq).toBe(26);
+ expect(store.hasEarlier).toBe(true);
+
+ store.dispose();
+ });
+
+ it("chat limit: unloading is deferred while the gate is closed, then catches up", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ let atBottom = false; // reader scrolled up
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 10,
+ canUnload: () => atBottom,
+ });
+
+ // 15 live tool-calls: over the limit, but the gate defers every trim.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ for (let i = 0; i < 15; i++) {
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: `tc${i}`,
+ toolName: "probe",
+ input: {},
+ stepId: `t1#${i}` as StepId,
+ }),
+ );
+ }
+ expect(store.chunks).toHaveLength(15);
+
+ // Reader returns to the bottom — the deferred trim now catches up.
+ // With no committed chunks, it drops the oldest provisional chunks
+ // (the in-flight turn) to stay within the limit.
+ atBottom = true;
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc15",
+ toolName: "probe",
+ input: {},
+ stepId: "t1#15" as StepId,
+ }),
+ );
+ // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
+ expect(store.chunks).toHaveLength(10);
+
+ store.dispose();
+ });
+
+ it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ let atBottom = false;
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ canUnload: () => atBottom,
+ });
+
+ // Seal a turn committing 130 chunks while the reader is scrolled up: no trim.
+ historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(130);
+ });
+
+ // Back at the bottom: the next fold trims whole quarters down to ≤ 100.
+ atBottom = true;
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk).
+ expect(store.chunks).toHaveLength(80);
+ expect(store.chunks[0]?.seq).toBe(51);
+
+ store.dispose();
+ });
+
+ it("chat limit: load windows a long cached conversation to 75% of the limit", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ // floor(100 × 0.75) = 75 newest chunks: seqs 426..500.
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(426);
+ expect(store.hasEarlier).toBe(true);
+ // The tail sync still used the cache's real cursor (not the window's edge).
+ expect(historySync.calls[0]?.sinceSeq).toBe(500);
+
+ store.dispose();
+ });
+
+ it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // The server holds 500 chunks; the windowed fetch returns the newest 75.
+ historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`).
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+ expect(historySync.calls[0]?.window).toEqual({ limit: 75 });
+
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(426);
+ // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) —
+ // no local watermark was ever set.
+ expect(store.hasEarlier).toBe(true);
+ // Only the window was shipped + cached (the point of CR-5).
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(75);
+
+ store.dispose();
+ });
+
+ it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]);
+ historySync.returnChunks = [makeStoredChunk(3)];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+ expect(historySync.calls[0]?.window).toBeUndefined();
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load(); // server-windowed: loaded + cached = 426..500
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ await store.showEarlier();
+
+ // Nothing below 426 was cached → fetched the missing run from the server.
+ const backfill = historySync.calls[1];
+ expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 });
+ expect(store.chunks).toHaveLength(100);
+ expect(store.chunks[0]?.seq).toBe(401);
+ expect(store.hasEarlier).toBe(true);
+ // The backfilled run is persisted: the NEXT page-in is cache-local.
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(100);
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier pages a quarter back in from the cache", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ await store.showEarlier(); // +ceil(100/4) = 25 older chunks
+ expect(store.chunks).toHaveLength(100);
+ expect(store.chunks[0]?.seq).toBe(401);
+ expect(store.hasEarlier).toBe(true);
+ // The cache reached deep enough — no server backfill was needed.
+ expect(historySync.calls).toHaveLength(1);
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load(); // window 75: hidden 1..5
+ expect(store.chunks).toHaveLength(75);
+ expect(store.hasEarlier).toBe(true);
+
+ await store.showEarlier(); // restores all 5 → nothing left below
+ expect(store.chunks).toHaveLength(80);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(store.hasEarlier).toBe(false);
+
+ store.dispose();
+ });
+
+ it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ // A sealed turn triggers syncTail, whose cache.commit returns the FULL
+ // merged cache (seqs 1..501) — the watermark must keep 1..425 out.
+ historySync.returnChunks = [makeStoredChunk(501)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" }));
+
+ await vi.waitFor(() => {
+ expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501);
+ });
+ expect(store.chunks[0]?.seq).toBe(426);
+ expect(store.chunks).toHaveLength(76);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: lowering the limit trims older committed chunks live", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // Load 80 committed chunks (under the limit — no trim yet).
+ historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(80);
+ });
+
+ // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs
+ // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain.
+ await store.setChatLimit(10);
+ expect(store.chunks).toHaveLength(8);
+ expect(store.chunks[0]?.seq).toBe(73);
+ expect(store.hasEarlier).toBe(true);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200.
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load();
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(126);
+ expect(store.hasEarlier).toBe(true);
+
+ // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks
+ // (seqs 51..125) from the cache. No server backfill (cache is deep enough).
+ await store.setChatLimit(200);
+ expect(historySync.calls).toHaveLength(1); // the load-time tail sync only
+ expect(store.chunks).toHaveLength(150);
+ expect(store.chunks[0]?.seq).toBe(51);
+ expect(store.hasEarlier).toBe(true); // 51 > 1
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
+ historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(126);
+
+ // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill
+ // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75).
+ await store.setChatLimit(200);
+ const backfill = historySync.calls[1];
+ expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 });
+ expect(store.chunks).toHaveLength(150);
+ expect(store.chunks[0]?.seq).toBe(51);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising refills all available older history (down to the origin)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101).
+ historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(76);
+ });
+ expect(store.chunks[0]?.seq).toBe(26);
+ expect(store.hasEarlier).toBe(true);
+
+ // Raise to 500 → window 375 → want 299 older. The cache holds only
+ // seqs 1..25 below the window (no more server-side) → restore all 25 →
+ // 101 loaded, reaching the origin.
+ await store.setChatLimit(500);
+ expect(store.chunks).toHaveLength(101);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(store.hasEarlier).toBe(false);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load(); // only 50 chunks → all loaded, window starts at seq 1
+ expect(store.chunks).toHaveLength(50);
+ expect(store.hasEarlier).toBe(false);
+ const callsAfterLoad = historySync.calls.length;
+
+ await store.setChatLimit(500); // raise → refill no-ops (oldest = 1)
+ expect(store.chunks).toHaveLength(50);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill
+
+ store.dispose();
+ });
+
+ it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(50);
+ });
+
+ // NaN normalizes to the default (256). prev was 100 → raise → refill,
+ // but the loaded window already starts at seq 1 (origin) → no-op.
+ await store.setChatLimit(Number.NaN);
+ expect(store.chunks).toHaveLength(50);
+
+ store.dispose();
+ });
+
+ it("resync is a no-op after dispose", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.dispose();
+ store.resync();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(historySync.calls).toHaveLength(0);
+ expect(metricsSync.calls).toHaveLength(0);
+ });
});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index 100449f..26c5590 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -4,139 +4,139 @@ import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export interface FakeTransport {
- /** All `chat.send` messages sent through the fake transport. */
- readonly sent: ChatSendMessage[];
- /** All `chat.queue` messages sent through the fake transport. */
- readonly sentQueue: ChatQueueMessage[];
- readonly impl: ChatTransport;
+ /** All `chat.send` messages sent through the fake transport. */
+ readonly sent: ChatSendMessage[];
+ /** All `chat.queue` messages sent through the fake transport. */
+ readonly sentQueue: ChatQueueMessage[];
+ readonly impl: ChatTransport;
}
export function createFakeTransport(): FakeTransport {
- const sent: ChatSendMessage[] = [];
- const sentQueue: ChatQueueMessage[] = [];
- return {
- sent,
- sentQueue,
- impl: {
- send(msg) {
- if (msg.type === "chat.queue") {
- sentQueue.push(msg);
- } else {
- sent.push(msg);
- }
- },
- },
- };
+ const sent: ChatSendMessage[] = [];
+ const sentQueue: ChatQueueMessage[] = [];
+ return {
+ sent,
+ sentQueue,
+ impl: {
+ send(msg) {
+ if (msg.type === "chat.queue") {
+ sentQueue.push(msg);
+ } else {
+ sent.push(msg);
+ }
+ },
+ },
+ };
}
export interface FakeHistorySync {
- readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
- /** Set the chunks to return on the next call. */
- returnChunks: readonly StoredChunk[];
- readonly impl: HistorySync;
+ readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
+ /** Set the chunks to return on the next call. */
+ returnChunks: readonly StoredChunk[];
+ readonly impl: HistorySync;
}
export function createFakeHistorySync(): FakeHistorySync {
- const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
- let returnChunks: readonly StoredChunk[] = [];
- return {
- calls,
- get returnChunks() {
- return returnChunks;
- },
- set returnChunks(v: readonly StoredChunk[]) {
- returnChunks = v;
- },
- impl: async (conversationId, sinceSeq, window) => {
- calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
- // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
- // so store tests exercise the real windowed flows. `sinceSeq` filtering is
- // deliberately NOT applied — tests set `returnChunks` to the slice they
- // mean the server to hold past the cursor.
- let chunks = returnChunks;
- const before = window?.beforeSeq;
- if (before !== undefined) {
- chunks = chunks.filter((c) => c.seq < before);
- }
- if (window?.limit !== undefined && chunks.length > window.limit) {
- chunks = chunks.slice(-window.limit);
- }
- const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
- return { chunks, latestSeq };
- },
- };
+ const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
+ let returnChunks: readonly StoredChunk[] = [];
+ return {
+ calls,
+ get returnChunks() {
+ return returnChunks;
+ },
+ set returnChunks(v: readonly StoredChunk[]) {
+ returnChunks = v;
+ },
+ impl: async (conversationId, sinceSeq, window) => {
+ calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
+ // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
+ // so store tests exercise the real windowed flows. `sinceSeq` filtering is
+ // deliberately NOT applied — tests set `returnChunks` to the slice they
+ // mean the server to hold past the cursor.
+ let chunks = returnChunks;
+ const before = window?.beforeSeq;
+ if (before !== undefined) {
+ chunks = chunks.filter((c) => c.seq < before);
+ }
+ if (window?.limit !== undefined && chunks.length > window.limit) {
+ chunks = chunks.slice(-window.limit);
+ }
+ const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
+ return { chunks, latestSeq };
+ },
+ };
}
export interface FakeMetricsSync {
- readonly calls: string[];
- returnTurns: import("@dispatch/wire").TurnMetrics[];
- /** If set, the next call will reject with this error. */
- nextError: string | undefined;
- readonly impl: MetricsSync;
+ readonly calls: string[];
+ returnTurns: import("@dispatch/wire").TurnMetrics[];
+ /** If set, the next call will reject with this error. */
+ nextError: string | undefined;
+ readonly impl: MetricsSync;
}
export function createFakeMetricsSync(): FakeMetricsSync {
- const calls: string[] = [];
- let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
- let nextError: string | undefined;
- return {
- calls,
- get returnTurns() {
- return returnTurns;
- },
- set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
- returnTurns = v;
- },
- get nextError() {
- return nextError;
- },
- set nextError(v: string | undefined) {
- nextError = v;
- },
- impl: async (conversationId) => {
- calls.push(conversationId);
- if (nextError !== undefined) {
- const err = nextError;
- nextError = undefined;
- throw new Error(err);
- }
- return { turns: returnTurns };
- },
- };
+ const calls: string[] = [];
+ let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
+ let nextError: string | undefined;
+ return {
+ calls,
+ get returnTurns() {
+ return returnTurns;
+ },
+ set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
+ returnTurns = v;
+ },
+ get nextError() {
+ return nextError;
+ },
+ set nextError(v: string | undefined) {
+ nextError = v;
+ },
+ impl: async (conversationId) => {
+ calls.push(conversationId);
+ if (nextError !== undefined) {
+ const err = nextError;
+ nextError = undefined;
+ throw new Error(err);
+ }
+ return { turns: returnTurns };
+ },
+ };
}
export interface FakeCache {
- readonly store: Map<string, StoredChunk[]>;
- readonly impl: ConversationCache;
+ readonly store: Map<string, StoredChunk[]>;
+ readonly impl: ConversationCache;
}
export function createFakeCache(): FakeCache {
- const store = new Map<string, StoredChunk[]>();
- return {
- store,
- impl: {
- async load(conversationId) {
- return store.get(conversationId) ?? [];
- },
- async commit(conversationId, incoming) {
- const existing = store.get(conversationId) ?? [];
- const seen = new Set(existing.map((c) => c.seq));
- const toAppend = incoming.filter((c) => !seen.has(c.seq));
- const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
- store.set(conversationId, merged);
- return merged;
- },
- async sinceSeq(conversationId) {
- const chunks = store.get(conversationId) ?? [];
- if (chunks.length === 0) return 0;
- return Math.max(...chunks.map((c) => c.seq));
- },
- async evictIfOverBudget() {
- return [];
- },
- async delete(conversationId) {
- store.delete(conversationId);
- },
- },
- };
+ const store = new Map<string, StoredChunk[]>();
+ return {
+ store,
+ impl: {
+ async load(conversationId) {
+ return store.get(conversationId) ?? [];
+ },
+ async commit(conversationId, incoming) {
+ const existing = store.get(conversationId) ?? [];
+ const seen = new Set(existing.map((c) => c.seq));
+ const toAppend = incoming.filter((c) => !seen.has(c.seq));
+ const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
+ store.set(conversationId, merged);
+ return merged;
+ },
+ async sinceSeq(conversationId) {
+ const chunks = store.get(conversationId) ?? [];
+ if (chunks.length === 0) return 0;
+ return Math.max(...chunks.map((c) => c.seq));
+ },
+ async evictIfOverBudget() {
+ return [];
+ },
+ async delete(conversationId) {
+ store.delete(conversationId);
+ },
+ },
+ };
}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index b8b3193..a2fd944 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -10,797 +10,797 @@ import ModelSelector from "./ui/ModelSelector.svelte";
import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte";
describe("ChatView", () => {
- it("renders a message's text chunk", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "text", text: "Hello world" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Hello world")).toBeInTheDocument();
- });
-
- it("renders multiple chunks", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Hi there")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- });
-
- it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
- const chunks: RenderedChunk[] = [
- { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
- ];
-
- let resolveEarlier: (() => void) | undefined;
- const onShowEarlier = vi.fn(
- () =>
- new Promise<void>((resolve) => {
- resolveEarlier = resolve;
- }),
- );
-
- render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } });
-
- const button = screen.getByRole("button", { name: /show earlier messages/i });
- const user = userEvent.setup();
- await user.click(button);
-
- expect(onShowEarlier).toHaveBeenCalledTimes(1);
- // While the page-in is awaited the button is disabled (no double-fire).
- expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled();
-
- resolveEarlier?.();
- await vi.waitFor(() => {
- expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled();
- });
- });
-
- it("hides the show-earlier button when nothing is unloaded", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false },
- ];
-
- render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } });
-
- expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
- });
-
- it("renders tool-call chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp/test.txt" },
- },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("read_file")).toBeInTheDocument();
- const pre = screen.getByText((content, element) => {
- return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
- });
- expect(pre).toBeInTheDocument();
- });
-
- it("renders tool-result chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents here",
- isError: false,
- },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("file contents here")).toBeInTheDocument();
- });
-
- it("renders error chunks with alert role", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "error", message: "Something failed" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- const alert = screen.getByRole("alert");
- expect(alert).toHaveTextContent("Something failed");
- });
-
- it("renders error chunks with code", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Rate limited")).toBeInTheDocument();
- expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
- });
-
- it("renders system chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "system",
- chunk: { type: "system", text: "System context loaded" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("System context loaded")).toBeInTheDocument();
- });
-
- it("renders provisional (in-flight) chunks without any dimming", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "text", text: "Streaming..." },
- provisional: true,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- // In-flight chunks render at full opacity (no faded "disabled" look).
- const wrapper = screen.getByText("Streaming...").closest("div");
- expect(wrapper).not.toHaveClass("opacity-50");
- });
-
- it("renders empty transcript", () => {
- render(ChatView, { props: { chunks: [] } });
-
- const log = screen.getByRole("log");
- expect(log).toBeInTheDocument();
- expect(log.children).toHaveLength(0);
- });
-
- it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "a",
- toolName: "read_file",
- input: { path: "/a" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 2,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "b",
- toolName: "list_dir",
- input: { path: "/b" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 3,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "a",
- toolName: "read_file",
- content: "contents-of-a",
- isError: false,
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks } });
-
- // Batched calls render as collapsible cards (one per call), not a list.
- const collapses = container.querySelectorAll(".collapse");
- expect(collapses).toHaveLength(2);
-
- // Both call names + the available result are shown; the result is absorbed
- // (no standalone tool-result card).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
- expect(screen.getByText("contents-of-a")).toBeInTheDocument();
- });
-
- it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think..." },
- provisional: true,
- streaming: true,
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks } });
-
- const collapse = container.querySelector(".collapse");
- expect(collapse).not.toBeNull();
- expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
- expect(collapse).not.toHaveClass("collapse-plus");
- // Visible bubble, like tool cards.
- expect(collapse).toHaveClass("bg-base-200");
- expect(collapse).toHaveClass("rounded-box");
- expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
- });
-
- it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => {
- const streaming: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "hmm" },
- provisional: true,
- streaming: true,
- },
- ];
-
- const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
-
- // Streaming: "Thinking" + loading dots.
- expect(screen.getByText("Thinking")).toBeInTheDocument();
- expect(screen.queryByText("Thoughts")).toBeNull();
- expect(container.querySelector(".loading")).not.toBeNull();
-
- // Open it.
- const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" });
- await userEvent.click(checkbox);
- expect(checkbox).toBeChecked();
-
- // Transition generating → completed/committed (seq assigned, no longer streaming).
- await rerender({
- chunks: [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "thinking", text: "hmm, all done" },
- provisional: false,
- },
- ],
- });
-
- // Completed: "Thoughts", no dots — and the open state survived the transition.
- expect(screen.getByText("Thoughts")).toBeInTheDocument();
- expect(screen.queryByText("Thinking")).toBeNull();
- expect(container.querySelector(".loading")).toBeNull();
- expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
- expect(container).toHaveTextContent("hmm, all done");
- });
-
- it("renders step and turn metrics as separate rows", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 100, outputTokens: 50 },
- durationMs: 1200,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- expect(screen.getByText("Hi")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
- expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
- expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
- });
-
- it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [],
- total: {
- turnId: "t1",
- usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 },
- steps: [],
- },
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks, turnMetrics } });
-
- expect(screen.getByText("Last turn:")).toBeInTheDocument();
- expect(screen.getByText("Chat Total:")).toBeInTheDocument();
- // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge
- const badges = container.querySelectorAll(".badge");
- expect(badges).toHaveLength(2);
- for (const b of badges) {
- expect(b.textContent).toBe("93%");
- expect(b.classList.contains("badge-success")).toBe(true);
- }
- });
-
- it("renders step-metrics inline after tool group", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "bash",
- input: { command: "ls" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 3,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "bash",
- content: "file.txt",
- isError: false,
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 4,
- role: "assistant",
- chunk: { type: "text", text: "Done!" },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 80, outputTokens: 20 },
- genTotalMs: 300,
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 80, outputTokens: 20 },
- durationMs: 500,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 80, outputTokens: 20 },
- genTotalMs: 300,
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Both step-metrics and turn-metrics render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
-
- // They are in separate elements (different rows)
- const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
- const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
- expect(stepEl).not.toBe(turnEl);
- });
-
- it("renders no metrics bubble when turnMetrics is empty", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics: [] } });
-
- expect(screen.getByText("Hi")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- expect(screen.queryByText(/step 1/)).toBeNull();
- expect(screen.queryByText(/^turn/)).toBeNull();
- });
-
- it("omits null view values from metrics bubbles", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Response" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 10, outputTokens: 5 },
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 10, outputTokens: 5 },
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 10, outputTokens: 5 },
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Step metrics rendered
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
- // Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
- // No "null" or "undefined" in the DOM
- expect(screen.queryByText("null")).toBeNull();
- expect(screen.queryByText("undefined")).toBeNull();
- });
-
- it("renders step text but no turn total for a progressive turn (total: null)", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- total: null,
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
-
- // Turn total should NOT render (total is null — turn still in progress)
- expect(screen.queryByText(/^turn/)).toBeNull();
- });
+ it("renders a message's text chunk", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello world" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
+ });
+
+ it("renders multiple chunks", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hi there")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ });
+
+ it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
+ ];
+
+ let resolveEarlier: (() => void) | undefined;
+ const onShowEarlier = vi.fn(
+ () =>
+ new Promise<void>((resolve) => {
+ resolveEarlier = resolve;
+ }),
+ );
+
+ render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } });
+
+ const button = screen.getByRole("button", { name: /show earlier messages/i });
+ const user = userEvent.setup();
+ await user.click(button);
+
+ expect(onShowEarlier).toHaveBeenCalledTimes(1);
+ // While the page-in is awaited the button is disabled (no double-fire).
+ expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled();
+
+ resolveEarlier?.();
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled();
+ });
+ });
+
+ it("hides the show-earlier button when nothing is unloaded", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false },
+ ];
+
+ render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } });
+
+ expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
+ });
+
+ it("renders tool-call chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ const pre = screen.getByText((content, element) => {
+ return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
+ });
+ expect(pre).toBeInTheDocument();
+ });
+
+ it("renders tool-result chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents here",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("file contents here")).toBeInTheDocument();
+ });
+
+ it("renders error chunks with alert role", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Something failed" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Something failed");
+ });
+
+ it("renders error chunks with code", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Rate limited")).toBeInTheDocument();
+ expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
+ });
+
+ it("renders system chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "system",
+ chunk: { type: "system", text: "System context loaded" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("System context loaded")).toBeInTheDocument();
+ });
+
+ it("renders provisional (in-flight) chunks without any dimming", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "text", text: "Streaming..." },
+ provisional: true,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ // In-flight chunks render at full opacity (no faded "disabled" look).
+ const wrapper = screen.getByText("Streaming...").closest("div");
+ expect(wrapper).not.toHaveClass("opacity-50");
+ });
+
+ it("renders empty transcript", () => {
+ render(ChatView, { props: { chunks: [] } });
+
+ const log = screen.getByRole("log");
+ expect(log).toBeInTheDocument();
+ expect(log.children).toHaveLength(0);
+ });
+
+ it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ // Batched calls render as collapsible cards (one per call), not a list.
+ const collapses = container.querySelectorAll(".collapse");
+ expect(collapses).toHaveLength(2);
+
+ // Both call names + the available result are shown; the result is absorbed
+ // (no standalone tool-result card).
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ });
+
+ it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "thinking", text: "Let me think..." },
+ provisional: true,
+ streaming: true,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ const collapse = container.querySelector(".collapse");
+ expect(collapse).not.toBeNull();
+ expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
+ expect(collapse).not.toHaveClass("collapse-plus");
+ // Visible bubble, like tool cards.
+ expect(collapse).toHaveClass("bg-base-200");
+ expect(collapse).toHaveClass("rounded-box");
+ expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
+ });
+
+ it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => {
+ const streaming: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "thinking", text: "hmm" },
+ provisional: true,
+ streaming: true,
+ },
+ ];
+
+ const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
+
+ // Streaming: "Thinking" + loading dots.
+ expect(screen.getByText("Thinking")).toBeInTheDocument();
+ expect(screen.queryByText("Thoughts")).toBeNull();
+ expect(container.querySelector(".loading")).not.toBeNull();
+
+ // Open it.
+ const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" });
+ await userEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+
+ // Transition generating → completed/committed (seq assigned, no longer streaming).
+ await rerender({
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "thinking", text: "hmm, all done" },
+ provisional: false,
+ },
+ ],
+ });
+
+ // Completed: "Thoughts", no dots — and the open state survived the transition.
+ expect(screen.getByText("Thoughts")).toBeInTheDocument();
+ expect(screen.queryByText("Thinking")).toBeNull();
+ expect(container.querySelector(".loading")).toBeNull();
+ expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
+ expect(container).toHaveTextContent("hmm, all done");
+ });
+
+ it("renders step and turn metrics as separate rows", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ durationMs: 1200,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
+ expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
+ });
+
+ it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 },
+ steps: [],
+ },
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Last turn:")).toBeInTheDocument();
+ expect(screen.getByText("Chat Total:")).toBeInTheDocument();
+ // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge
+ const badges = container.querySelectorAll(".badge");
+ expect(badges).toHaveLength(2);
+ for (const b of badges) {
+ expect(b.textContent).toBe("93%");
+ expect(b.classList.contains("badge-success")).toBe(true);
+ }
+ });
+
+ it("renders step-metrics inline after tool group", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "bash",
+ input: { command: "ls" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "bash",
+ content: "file.txt",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: { type: "text", text: "Done!" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 80, outputTokens: 20 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Both step-metrics and turn-metrics render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+
+ // They are in separate elements (different rows)
+ const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
+ expect(stepEl).not.toBe(turnEl);
+ });
+
+ it("renders no metrics bubble when turnMetrics is empty", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics: [] } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.queryByText(/step 1/)).toBeNull();
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
+
+ it("omits null view values from metrics bubbles", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Response" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics rendered
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ // Turn metrics rendered
+ expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ // No "null" or "undefined" in the DOM
+ expect(screen.queryByText("null")).toBeNull();
+ expect(screen.queryByText("undefined")).toBeNull();
+ });
+
+ it("renders step text but no turn total for a progressive turn (total: null)", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: null,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics should render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+
+ // Turn total should NOT render (total is null — turn still in progress)
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
});
describe("Composer", () => {
- it("calls onSend with the typed text and clears", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("calls onSend with the typed text and clears", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Hello world");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Hello world");
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
- expect(onSend).toHaveBeenCalledTimes(1);
- expect(onSend).toHaveBeenCalledWith("Hello world");
- expect(textarea).toHaveValue("");
- });
+ expect(onSend).toHaveBeenCalledTimes(1);
+ expect(onSend).toHaveBeenCalledWith("Hello world");
+ expect(textarea).toHaveValue("");
+ });
- it("does not call onSend with empty text", async () => {
- const onSend = vi.fn();
- const _user = userEvent.setup();
+ it("does not call onSend with empty text", async () => {
+ const onSend = vi.fn();
+ const _user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const sendButton = screen.getByRole("button", { name: "Send" });
- expect(sendButton).toBeDisabled();
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton).toBeDisabled();
- expect(onSend).not.toHaveBeenCalled();
- });
+ expect(onSend).not.toHaveBeenCalled();
+ });
- it("trims whitespace before sending", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("trims whitespace before sending", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, " hello ");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, " hello ");
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
- expect(onSend).toHaveBeenCalledWith("hello");
- });
+ expect(onSend).toHaveBeenCalledWith("hello");
+ });
- it("sends on Enter key (without Shift)", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("sends on Enter key (without Shift)", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Test message{Enter}");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Test message{Enter}");
- expect(onSend).toHaveBeenCalledWith("Test message");
- });
+ expect(onSend).toHaveBeenCalledWith("Test message");
+ });
- it("does not send on Shift+Enter", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("does not send on Shift+Enter", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
- expect(onSend).not.toHaveBeenCalled();
- });
+ expect(onSend).not.toHaveBeenCalled();
+ });
});
describe("ModelSelector", () => {
- const optionValues = (el: HTMLElement): string[] =>
- within(el)
- .getAllByRole("option")
- .map((o) => (o as HTMLOptionElement).value);
-
- it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
- const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
- render(ModelSelector, {
- props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
- });
-
- const keySelect = screen.getByRole("combobox", { name: "Key selector" });
- const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
- expect(keySelect).toHaveValue("anthropic");
- expect(modelSelect).toHaveValue("claude-3");
-
- expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
- // only the models under the selected key
- expect(optionValues(modelSelect)).toEqual(["claude-3"]);
- });
-
- it("selecting a key switches to the first model under it", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4o", onSelect },
- });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
- });
-
- it("selecting a model keeps the current key", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "openai/gpt-4o"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4", onSelect },
- });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
- });
+ const optionValues = (el: HTMLElement): string[] =>
+ within(el)
+ .getAllByRole("option")
+ .map((o) => (o as HTMLOptionElement).value);
+
+ it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
+ render(ModelSelector, {
+ props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(keySelect).toHaveValue("anthropic");
+ expect(modelSelect).toHaveValue("claude-3");
+
+ expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // only the models under the selected key
+ expect(optionValues(modelSelect)).toEqual(["claude-3"]);
+ });
+
+ it("selecting a key switches to the first model under it", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4o", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
+ });
+
+ it("selecting a model keeps the current key", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
+ });
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
-
- const select = screen.getByRole("combobox", { name: "Reasoning effort" });
- expect(select).toHaveValue("high");
- expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
- // All five ladder levels are offered.
- expect(within(select).getAllByRole("option")).toHaveLength(5);
- });
-
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
-
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
- });
-
- it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
- ok: true as const,
- reasoningEffort: level,
- }));
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- expect(save).toHaveBeenCalledTimes(1);
- expect(save).toHaveBeenCalledWith("max");
- await vi.waitFor(() => {
- expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
- });
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
- });
-
- it("a failed save shows the error and reverts to the persisted value", async () => {
- const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- await vi.waitFor(() => {
- expect(screen.getByText("nope")).toBeInTheDocument();
- });
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low");
- });
-
- it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
- const save = vi.fn(
- () =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
- resolveSave = resolve;
- }),
- );
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
-
- resolveSave?.({ ok: true, reasoningEffort: "max" });
- await vi.waitFor(() => {
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
- });
- });
+ it("renders null (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+
+ const select = screen.getByRole("combobox", { name: "Reasoning effort" });
+ expect(select).toHaveValue("high");
+ expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
+ // All five ladder levels are offered.
+ expect(within(select).getAllByRole("option")).toHaveLength(5);
+ });
+
+ it("renders a persisted level as selected", () => {
+ render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
+ });
+
+ it("selecting a level saves it via the injected port and confirms", async () => {
+ const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ reasoningEffort: level,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: null, save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledWith("max");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
+ });
+
+ it("a failed save shows the error and reverts to the persisted value", async () => {
+ const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low");
+ });
+
+ it("disables the select while a save is in flight (no double-fire)", async () => {
+ let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ const save = vi.fn(
+ () =>
+ new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ resolveSave = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: null, save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
+
+ resolveSave?.({ ok: true, reasoningEffort: "max" });
+ await vi.waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
+ });
+ });
});
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index f2899c7..e72f639 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,294 +1,347 @@
<script lang="ts">
- import type { TurnProviderRetryEvent } from "@dispatch/wire";
- import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
- import {
- interleaveTurnMetrics,
- viewCacheRate,
- viewExpectedCache,
- viewStepMetrics,
- viewTurnMetrics,
- type TurnMetricsEntry,
- } from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import type { TurnProviderRetryEvent } from "@dispatch/wire";
+ import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
+ import {
+ interleaveTurnMetrics,
+ viewCacheRate,
+ viewExpectedCache,
+ viewStepMetrics,
+ viewTurnMetrics,
+ type TurnMetricsEntry,
+ } from "../../../core/metrics";
+ import { Markdown } from "../../markdown";
- const badgeClass = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- } as const;
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
- let {
- chunks,
- turnMetrics = [],
- hasEarlier = false,
- onShowEarlier,
- thinkingKeyBase = 0,
- providerRetry = null,
- }: {
- chunks: readonly RenderedChunk[];
- turnMetrics?: readonly TurnMetricsEntry[];
- /** Earlier history is unloaded (chat limit) and can be paged back in. */
- hasEarlier?: boolean;
- /** Page earlier history back in; the caller owns scroll-position preservation. */
- onShowEarlier?: () => Promise<void>;
- /**
- * Ordinal base for thinking-collapse keys: the count of thinking chunks
- * unloaded by the chat limit, so the remaining ordinals don't shift (and
- * swap collapse state) when a trim removes older thinking blocks.
- */
- thinkingKeyBase?: number;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when
- * no retry is pending → renders the transient yellow "retrying…" banner.
- * Never persisted (never part of the message history); coalesces to the
- * newest attempt + delay, and is cleared when content resumes / turn ends.
- */
- providerRetry?: TurnProviderRetryEvent | null;
- } = $props();
+ let {
+ chunks,
+ turnMetrics = [],
+ hasEarlier = false,
+ onShowEarlier,
+ thinkingKeyBase = 0,
+ providerRetry = null,
+ }: {
+ chunks: readonly RenderedChunk[];
+ turnMetrics?: readonly TurnMetricsEntry[];
+ /** Earlier history is unloaded (chat limit) and can be paged back in. */
+ hasEarlier?: boolean;
+ /** Page earlier history back in; the caller owns scroll-position preservation. */
+ onShowEarlier?: () => Promise<void>;
+ /**
+ * Ordinal base for thinking-collapse keys: the count of thinking chunks
+ * unloaded by the chat limit, so the remaining ordinals don't shift (and
+ * swap collapse state) when a trim removes older thinking blocks.
+ */
+ thinkingKeyBase?: number;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when
+ * no retry is pending → renders the transient yellow "retrying…" banner.
+ * Never persisted (never part of the message history); coalesces to the
+ * newest attempt + delay, and is cleared when content resumes / turn ends.
+ */
+ providerRetry?: TurnProviderRetryEvent | null;
+ } = $props();
- // True while a show-earlier page-in is awaited (disables the button).
- let loadingEarlier = $state(false);
+ // True while a show-earlier page-in is awaited (disables the button).
+ let loadingEarlier = $state(false);
- async function showEarlier() {
- if (!onShowEarlier || loadingEarlier) return;
- loadingEarlier = true;
- try {
- await onShowEarlier();
- } finally {
- loadingEarlier = false;
- }
- }
+ async function showEarlier() {
+ if (!onShowEarlier || loadingEarlier) return;
+ loadingEarlier = true;
+ try {
+ await onShowEarlier();
+ } finally {
+ loadingEarlier = false;
+ }
+ }
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks));
- const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
- // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
- // survives the provisional→committed (seq null → seq N) transition, so the
- // collapse's open/close state is NOT lost when a turn seals. The ordinal
- // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing
- // older thinking blocks. (App isolates these keys per conversation via {#key}.)
- const keyedRows = $derived.by(() => {
- let thinking = thinkingKeyBase;
- return rows.map((row, i) => {
- if (row.kind === "step-metrics") {
- return { row, key: `s${row.step.stepId}` };
- }
- if (row.kind === "turn-metrics") {
- return { row, key: `m${row.turn.turnId}` };
- }
- const group = row.group;
- let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
- } else if (group.chunk.chunk.type === "thinking") {
- key = `think${thinking++}`;
- } else if (group.chunk.seq != null) {
- key = `c${group.chunk.seq}`;
- } else {
- key = `p${i}`;
- }
- return { row, key };
- });
- });
+ // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
+ // survives the provisional→committed (seq null → seq N) transition, so the
+ // collapse's open/close state is NOT lost when a turn seals. The ordinal
+ // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing
+ // older thinking blocks. (App isolates these keys per conversation via {#key}.)
+ const keyedRows = $derived.by(() => {
+ let thinking = thinkingKeyBase;
+ return rows.map((row, i) => {
+ if (row.kind === "step-metrics") {
+ return { row, key: `s${row.step.stepId}` };
+ }
+ if (row.kind === "turn-metrics") {
+ return { row, key: `m${row.turn.turnId}` };
+ }
+ const group = row.group;
+ let key: string;
+ if (group.kind === "tool-batch") {
+ key = `b${group.stepId}`;
+ } else if (group.chunk.chunk.type === "thinking") {
+ key = `think${thinking++}`;
+ } else if (group.chunk.seq != null) {
+ key = `c${group.chunk.seq}`;
+ } else {
+ key = `p${i}`;
+ }
+ return { row, key };
+ });
+ });
</script>
{#snippet chunkRow(rendered: RenderedChunk)}
- {#if rendered.role === "user"}
- <!-- User: a speech bubble, left-aligned -->
- <div class="chat chat-start">
- <div class="chat-bubble chat-bubble-primary">
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {/if}
- </div>
- </div>
- {:else if rendered.chunk.type === "thinking"}
- <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
- (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
- while generating, then "Thoughts" with no dots once complete. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle thoughts" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
- {#if rendered.streaming}
- <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
- </div>
- </div>
- </div>
- </div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "tool-call"}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{rendered.chunk.toolName}</span>
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- </div>
- {:else}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool result" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" class:text-error={rendered.chunk.isError}>
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{rendered.chunk.toolName}</span>
- {#if rendered.chunk.isError}
- <span class="badge badge-error badge-xs">error</span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
- </div>
- </div>
- {/if}
- </div>
- </div>
- {:else}
- <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
- chat-start grid as the user bubble, so it inherits identical left spacing. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "text"}
- <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
- {:else if rendered.chunk.type === "error"}
- <div class="text-error" role="alert">
- {rendered.chunk.message}
- {#if rendered.chunk.code}
- <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
- {/if}
- </div>
- {:else if rendered.chunk.type === "system"}
- <div class="text-sm opacity-70">{rendered.chunk.text}</div>
- {/if}
- </div>
- </div>
- {/if}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary">
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {/if}
+ </div>
+ </div>
+ {:else if rendered.chunk.type === "thinking"}
+ <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
+ (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
+ while generating, then "Thoughts" with no dots once complete. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle thoughts" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
+ {#if rendered.streaming}
+ <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
+ <!-- Single tool call/result: a collapsible card (collapsed by default,
+ like thinking). Title shows the tool name; content shows the
+ input/output. Same chat-start grid shim as the thinking block. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ {#if rendered.chunk.type === "tool-call"}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool call" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{rendered.chunk.toolName}</span>
+ </div>
+ <div class="collapse-content">
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(
+ rendered.chunk.input,
+ null,
+ 2,
+ )}</pre>
+ </div>
+ </div>
+ {:else}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool result" />
+ <div
+ class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"
+ class:text-error={rendered.chunk.isError}
+ >
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{rendered.chunk.toolName}</span>
+ {#if rendered.chunk.isError}
+ <span class="badge badge-error badge-xs">error</span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+ {:else}
+ <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
+ chat-start grid as the user bubble, so it inherits identical left spacing. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ {#if rendered.chunk.type === "text"}
+ <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
+ {:else if rendered.chunk.type === "error"}
+ <div class="text-error" role="alert">
+ {rendered.chunk.message}
+ {#if rendered.chunk.code}
+ <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
+ {/if}
+ </div>
+ {:else if rendered.chunk.type === "system"}
+ <div class="text-sm opacity-70">{rendered.chunk.text}</div>
+ {/if}
+ </div>
+ </div>
+ {/if}
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#if hasEarlier && onShowEarlier}
- <!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
- <div class="flex justify-center">
- <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}>
- {#if loadingEarlier}
- <span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
- Loading earlier messages…
- {:else}
- Show earlier messages
- {/if}
- </button>
- </div>
- {/if}
- {#each keyedRows as { row, key } (key)}
- {#if row.kind === "step-metrics"}
- {@const sv = viewStepMetrics(row.step, row.index)}
- <div class="chat chat-start">
- <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="text-xs opacity-70">
- {sv.label} · {sv.tokensLabel}
- {#if sv.tps} · {sv.tps}{/if}
- {#if sv.genTotal} · {sv.genTotal}{/if}
- </div>
- </div>
- </div>
- {:else if row.kind === "turn-metrics"}
- {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
- {@const lastCache = viewCacheRate(row.turn.usage)}
- {@const chatCache = viewCacheRate(row.cumulativeUsage)}
- {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
- <div class="chat chat-start">
- <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="flex flex-col gap-1 text-xs">
- <div class="opacity-70">
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
- {#if turnView.tps} · {turnView.tps}{/if}
- {#if turnView.duration} · {turnView.duration}{/if}
- </div>
- <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span class="flex items-center gap-1">
- <span class="opacity-70">Last turn:</span>
- <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span>
- </span>
- <span class="flex items-center gap-1">
- <span class="opacity-70">Chat Total:</span>
- <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span>
- </span>
- {#if retention}
- <span class="flex items-center gap-1">
- <span class="opacity-70">Retention:</span>
- <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
- </span>
- {/if}
- </div>
- </div>
- </div>
- </div>
- {:else if row.group.kind === "single"}
- {@render chunkRow(row.group.chunk)}
- {:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry (entry.call.toolCallId)}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{entry.call.toolName}</span>
- {#if entry.result?.isError}
- <span class="badge badge-error badge-xs">error</span>
- {:else if entry.result === null}
- <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre>
- {#if entry.result}
- <pre class="mt-1 max-h-96 overflow-auto text-xs" class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </div>
- </div>
- {/each}
- </div>
- </div>
- </div>
- {/if}
- {/each}
- {#if providerRetry}
- {@const rv = viewProviderRetry(providerRetry)}
- <!-- Transient yellow warning: a provider error is being retried with backoff.
- NOT a message chunk (never persisted/replayed) — a live UI notification only,
- shown where the reply would appear. Coalesces to the newest attempt + delay,
- and is cleared (foldEvent) when content resumes or the turn ends. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div class="chat-bubble w-full bg-transparent">
- <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
- <div class="flex flex-wrap items-center gap-2 font-medium">
- <span aria-hidden="true">⚠</span>
- <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
- {#if rv.code}
- <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
- {/if}
- </div>
- <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
- </div>
- </div>
- </div>
- {/if}
+ {#if hasEarlier && onShowEarlier}
+ <!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
+ <div class="flex justify-center">
+ <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}>
+ {#if loadingEarlier}
+ <span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
+ Loading earlier messages…
+ {:else}
+ Show earlier messages
+ {/if}
+ </button>
+ </div>
+ {/if}
+ {#each keyedRows as { row, key } (key)}
+ {#if row.kind === "step-metrics"}
+ {@const sv = viewStepMetrics(row.step, row.index)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="text-xs opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps}
+ · {sv.tps}{/if}
+ {#if sv.genTotal}
+ · {sv.genTotal}{/if}
+ </div>
+ </div>
+ </div>
+ {:else if row.kind === "turn-metrics"}
+ {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
+ {@const lastCache = viewCacheRate(row.turn.usage)}
+ {@const chatCache = viewCacheRate(row.cumulativeUsage)}
+ {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex flex-col gap-1 text-xs">
+ <div class="opacity-70">
+ {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
+ {#if turnView.tps}
+ · {turnView.tps}{/if}
+ {#if turnView.duration}
+ · {turnView.duration}{/if}
+ </div>
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Last turn:</span>
+ <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span>
+ </span>
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Chat Total:</span>
+ <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span>
+ </span>
+ {#if retention}
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Retention:</span>
+ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
+ </span>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+ {:else if row.group.kind === "single"}
+ {@render chunkRow(row.group.chunk)}
+ {:else}
+ <!-- Batched tool calls (one step): each entry is a collapsible card.
+ Click to expand and see the input/output. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="flex flex-col gap-1">
+ {#each row.group.entries as entry (entry.call.toolCallId)}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool call" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{entry.call.toolName}</span>
+ {#if entry.result?.isError}
+ <span class="badge badge-error badge-xs">error</span>
+ {:else if entry.result === null}
+ <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(
+ entry.call.input,
+ null,
+ 2,
+ )}</pre>
+ {#if entry.result}
+ <pre
+ class="mt-1 max-h-96 overflow-auto text-xs"
+ class:text-error={entry.result.isError}>{entry.result.content}</pre>
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ {/if}
+ {/each}
+ {#if providerRetry}
+ {@const rv = viewProviderRetry(providerRetry)}
+ <!-- Transient yellow warning: a provider error is being retried with backoff.
+ NOT a message chunk (never persisted/replayed) — a live UI notification only,
+ shown where the reply would appear. Coalesces to the newest attempt + delay,
+ and is cleared (foldEvent) when content resumes or the turn ends. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
+ <div class="flex flex-wrap items-center gap-2 font-medium">
+ <span aria-hidden="true">⚠</span>
+ <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
+ {#if rv.code}
+ <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
+ {/if}
+ </div>
+ <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
+ </div>
+ </div>
+ </div>
+ {/if}
</div>
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte
index 7bec984..5014e5c 100644
--- a/src/features/chat/ui/CompactionView.svelte
+++ b/src/features/chat/ui/CompactionView.svelte
@@ -1,154 +1,154 @@
<script lang="ts">
- export type CompactNowResult =
- | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
- | { readonly ok: false; readonly error: string };
+ export type CompactNowResult =
+ | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
+ | { readonly ok: false; readonly error: string };
- export type SaveCompactPercentResult =
- | { readonly ok: true; readonly percent: number }
- | { readonly ok: false; readonly error: string };
+ export type SaveCompactPercentResult =
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
- let {
- percent,
- canCompact,
- compactNow,
- savePercent,
- }: {
- /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */
- percent: number | null;
- /** Whether a real conversation is focused (a draft has nothing to compact). */
- canCompact: boolean;
- compactNow: () => Promise<CompactNowResult | null>;
- savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
- } = $props();
+ let {
+ percent,
+ canCompact,
+ compactNow,
+ savePercent,
+ }: {
+ /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */
+ percent: number | null;
+ /** Whether a real conversation is focused (a draft has nothing to compact). */
+ canCompact: boolean;
+ compactNow: () => Promise<CompactNowResult | null>;
+ savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
+ } = $props();
- const DEFAULT_PERCENT = 85;
+ const DEFAULT_PERCENT = 85;
- let compacting = $state(false);
- let compactError = $state<string | null>(null);
- let compactResult = $state<{ summarized: number; kept: number } | null>(null);
+ let compacting = $state(false);
+ let compactError = $state<string | null>(null);
+ let compactResult = $state<{ summarized: number; kept: number } | null>(null);
- let percentInput = $state("");
- let savingPercent = $state(false);
- let percentError = $state<string | null>(null);
- let percentSaved = $state(false);
+ let percentInput = $state("");
+ let savingPercent = $state(false);
+ let percentError = $state<string | null>(null);
+ let percentSaved = $state(false);
- // Sync the input from the prop when it changes (focus switch / initial load).
- let lastPercent = $state<number | null>(null);
- $effect(() => {
- if (percent !== lastPercent) {
- lastPercent = percent;
- percentInput = percent !== null ? String(percent) : "";
- percentError = null;
- percentSaved = false;
- }
- });
+ // Sync the input from the prop when it changes (focus switch / initial load).
+ let lastPercent = $state<number | null>(null);
+ $effect(() => {
+ if (percent !== lastPercent) {
+ lastPercent = percent;
+ percentInput = percent !== null ? String(percent) : "";
+ percentError = null;
+ percentSaved = false;
+ }
+ });
- const percentLabel = $derived(
- percent == null
- ? "Loading…"
- : percent === 0
- ? "Disabled (manual only)"
- : percent === DEFAULT_PERCENT
- ? `${percent}% (default)`
- : `${percent}%`,
- );
+ const percentLabel = $derived(
+ percent == null
+ ? "Loading…"
+ : percent === 0
+ ? "Disabled (manual only)"
+ : percent === DEFAULT_PERCENT
+ ? `${percent}% (default)`
+ : `${percent}%`,
+ );
- async function handleCompact() {
- if (compacting || !canCompact) return;
- compacting = true;
- compactError = null;
- compactResult = null;
- const result = await compactNow();
- compacting = false;
- if (result === null) return;
- if (result.ok) {
- compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
- } else {
- compactError = result.error;
- }
- }
+ async function handleCompact() {
+ if (compacting || !canCompact) return;
+ compacting = true;
+ compactError = null;
+ compactResult = null;
+ const result = await compactNow();
+ compacting = false;
+ if (result === null) return;
+ if (result.ok) {
+ compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
+ } else {
+ compactError = result.error;
+ }
+ }
- async function handleSavePercent() {
- const value = Number.parseInt(percentInput, 10);
- if (Number.isNaN(value) || value < 0 || value > 100) {
- percentError = "Must be 0-100";
- return;
- }
- savingPercent = true;
- percentError = null;
- percentSaved = false;
- const result = await savePercent(value);
- savingPercent = false;
- if (result === null) return;
- if (result.ok) {
- percentSaved = true;
- } else {
- percentError = result.error;
- }
- }
+ async function handleSavePercent() {
+ const value = Number.parseInt(percentInput, 10);
+ if (Number.isNaN(value) || value < 0 || value > 100) {
+ percentError = "Must be 0-100";
+ return;
+ }
+ savingPercent = true;
+ percentError = null;
+ percentSaved = false;
+ const result = await savePercent(value);
+ savingPercent = false;
+ if (result === null) return;
+ if (result.ok) {
+ percentSaved = true;
+ } else {
+ percentError = result.error;
+ }
+ }
</script>
<div class="flex flex-col gap-3">
- <!-- Manual compaction -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={!canCompact || compacting}
- onclick={handleCompact}
- >
- {#if compacting}
- <span class="loading loading-spinner loading-xs"></span>
- Compacting…
- {:else}
- Compact now
- {/if}
- </button>
- {#if !canCompact}
- <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
- {:else if compactError}
- <p class="text-xs text-error">{compactError}</p>
- {:else if compactResult}
- <p class="text-xs text-success">
- Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
- </p>
- {:else}
- <p class="text-xs opacity-50">
- Summarizes old messages into a system summary + retains the most recent messages.
- </p>
- {/if}
- </section>
+ <!-- Manual compaction -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canCompact || compacting}
+ onclick={handleCompact}
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact now
+ {/if}
+ </button>
+ {#if !canCompact}
+ <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
+ {:else if compactError}
+ <p class="text-xs text-error">{compactError}</p>
+ {:else if compactResult}
+ <p class="text-xs text-success">
+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
+ </p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Summarizes old messages into a system summary + retains the most recent messages.
+ </p>
+ {/if}
+ </section>
- <!-- Auto-compact percent -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
- <div class="flex items-center gap-2">
- <input
- type="number"
- class="input input-bordered input-sm w-24"
- min="0"
- max="100"
- placeholder={String(DEFAULT_PERCENT)}
- value={percentInput}
- disabled={savingPercent}
- onchange={handleSavePercent}
- aria-label="Compact percent (0-100)"
- />
- <span class="text-xs opacity-60">%</span>
- {#if savingPercent}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
- <p class="text-xs opacity-50">
- Current: {percentLabel}
- <br />
- 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
- </p>
- {#if percentError}
- <p class="text-xs text-error">{percentError}</p>
- {:else if percentSaved}
- <p class="text-xs text-success">Saved.</p>
- {/if}
- </section>
+ <!-- Auto-compact percent -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ min="0"
+ max="100"
+ placeholder={String(DEFAULT_PERCENT)}
+ value={percentInput}
+ disabled={savingPercent}
+ onchange={handleSavePercent}
+ aria-label="Compact percent (0-100)"
+ />
+ <span class="text-xs opacity-60">%</span>
+ {#if savingPercent}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <p class="text-xs opacity-50">
+ Current: {percentLabel}
+ <br />
+ 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
+ </p>
+ {#if percentError}
+ <p class="text-xs text-error">{percentError}</p>
+ {:else if percentSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
</div>
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index fe9ea94..96b4b3a 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,198 +1,198 @@
<script lang="ts">
- import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
- const FALLBACK_CONTEXT_WINDOW = 1_000_000;
- const MAX_LINES = 7;
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
+ const MAX_LINES = 7;
- let {
- onSend,
- onQueue,
- onStop,
- contextSize = undefined,
- contextWindow = undefined,
- status = "idle",
- }: {
- onSend: (text: string) => void;
- /**
- * Enqueue a steering message (`chat.queue`). When provided AND the status
- * is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. When absent, `onSend` is
- * used regardless (tests / non-steering contexts).
- */
- onQueue?: (text: string) => void;
- /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
- onStop?: () => void;
- // Current context occupancy (latest turn's contextSize), or `undefined`
- // when unknown — the status bar then shows "— tokens", never 0%.
- contextSize?: number | undefined;
- /** Per-model context window (max tokens) from `GET /models` modelInfo. */
- contextWindow?: number | undefined;
- // Coarse agent status for the status-bar icon.
- status?: "idle" | "running" | "error";
- } = $props();
+ let {
+ onSend,
+ onQueue,
+ onStop,
+ contextSize = undefined,
+ contextWindow = undefined,
+ status = "idle",
+ }: {
+ onSend: (text: string) => void;
+ /**
+ * Enqueue a steering message (`chat.queue`). When provided AND the status
+ * is `running`, the send button becomes a "Queue" button that steers the
+ * in-flight turn instead of starting a new one. When absent, `onSend` is
+ * used regardless (tests / non-steering contexts).
+ */
+ onQueue?: (text: string) => void;
+ /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
+ onStop?: () => void;
+ // Current context occupancy (latest turn's contextSize), or `undefined`
+ // when unknown — the status bar then shows "— tokens", never 0%.
+ contextSize?: number | undefined;
+ /** Per-model context window (max tokens) from `GET /models` modelInfo. */
+ contextWindow?: number | undefined;
+ // Coarse agent status for the status-bar icon.
+ status?: "idle" | "running" | "error";
+ } = $props();
- let text = $state("");
- let inputEl: HTMLTextAreaElement | undefined;
+ let text = $state("");
+ let inputEl: HTMLTextAreaElement | undefined;
- const hasText = $derived(text.trim().length > 0);
- const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
- const usage = $derived(computeContextUsage(contextSize, effectiveMax));
- const hasUsage = $derived(contextSize !== undefined);
+ const hasText = $derived(text.trim().length > 0);
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
+ const hasUsage = $derived(contextSize !== undefined);
- // One button, three modes:
- // - idle → "Send" (starts a turn via chat.send)
- // - running + text → "Queue" (steers via chat.queue)
- // - running + empty → "Stop" (aborts via POST /stop)
- const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
- if (status === "running" && !hasText && onStop !== undefined) return "stop";
- if (status === "running" && hasText && onQueue !== undefined) return "queue";
- return "send";
- });
- const placeholder = $derived(status === "running" ? "Steer the conversation..." : "Type a message...");
+ // One button, three modes:
+ // - idle → "Send" (starts a turn via chat.send)
+ // - running + text → "Queue" (steers via chat.queue)
+ // - running + empty → "Stop" (aborts via POST /stop)
+ const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
+ if (status === "running" && !hasText && onStop !== undefined) return "stop";
+ if (status === "running" && hasText && onQueue !== undefined) return "queue";
+ return "send";
+ });
+ const placeholder = $derived(
+ status === "running" ? "Steer the conversation..." : "Type a message...",
+ );
- // As the window fills, escalate color: calm → warning → danger.
- function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
- }
+ // As the window fills, escalate color: calm → warning → danger.
+ function fillClass(pct: number): string {
+ if (pct >= 90) return "progress-error";
+ if (pct >= 70) return "progress-warning";
+ return "progress-success";
+ }
- function resize(): void {
- const el = inputEl;
- if (!el) return;
- el.style.height = "auto";
- const style = getComputedStyle(el);
- const lineHeight = Number.parseFloat(style.lineHeight) || 20;
- const paddingY =
- Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
- const borderY =
- Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
- const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
- const next = Math.min(el.scrollHeight, maxHeight);
- el.style.height = `${next}px`;
- el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
- }
+ function resize(): void {
+ const el = inputEl;
+ if (!el) return;
+ el.style.height = "auto";
+ const style = getComputedStyle(el);
+ const lineHeight = Number.parseFloat(style.lineHeight) || 20;
+ const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
+ const borderY =
+ Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
+ const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
+ const next = Math.min(el.scrollHeight, maxHeight);
+ el.style.height = `${next}px`;
+ el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
+ }
- // Re-run resize whenever the value changes (covers programmatic clears too).
- $effect(() => {
- void text;
- resize();
- });
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
- function handleSubmit(): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- if (buttonMode === "queue") {
- onQueue?.(trimmed);
- } else {
- onSend(trimmed);
- }
- text = "";
- }
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ if (buttonMode === "queue") {
+ onQueue?.(trimmed);
+ } else {
+ onSend(trimmed);
+ }
+ text = "";
+ }
- function handleKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
</script>
<form
- class="flex flex-col"
- onsubmit={(e) => {
- e.preventDefault();
- handleSubmit();
- }}
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
>
- <!-- Top bar: expanding textarea + single context-aware button -->
- <div class="flex items-end gap-2 px-4 pt-3 pb-2">
- <textarea
- bind:this={inputEl}
- class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
- bind:value={text}
- onkeydown={handleKeydown}
- placeholder={placeholder}
- rows="1"
- aria-label="Message input"
- ></textarea>
- {#if buttonMode === "stop"}
- <button
- class="btn btn-error w-20 shrink-0"
- type="button"
- aria-label="Stop generation"
- onclick={() => onStop?.()}
- >
- Stop
- </button>
- {:else}
- <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
- {buttonMode === "queue" ? "Queue" : "Send"}
- </button>
- {/if}
- </div>
+ <!-- Top bar: expanding textarea + single context-aware button -->
+ <div class="flex items-end gap-2 px-4 pt-3 pb-2">
+ <textarea
+ bind:this={inputEl}
+ class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
+ bind:value={text}
+ onkeydown={handleKeydown}
+ {placeholder}
+ rows="1"
+ aria-label="Message input"></textarea>
+ {#if buttonMode === "stop"}
+ <button
+ class="btn btn-error w-20 shrink-0"
+ type="button"
+ aria-label="Stop generation"
+ onclick={() => onStop?.()}
+ >
+ Stop
+ </button>
+ {:else}
+ <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
+ {buttonMode === "queue" ? "Queue" : "Send"}
+ </button>
+ {/if}
+ </div>
- <!-- Bottom status bar: status icon · context-window fill · token count -->
- <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
- <span class="shrink-0">
- {#if status === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
- {:else if status === "error"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-error"
- aria-label="Error"
- >
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- {:else}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2.5"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-success"
- aria-label="Idle"
- >
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {/if}
- </span>
+ <!-- Bottom status bar: status icon · context-window fill · token count -->
+ <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
+ <span class="shrink-0">
+ {#if status === "running"}
+ <span class="loading loading-spinner loading-xs text-primary"></span>
+ {:else if status === "error"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-error"
+ aria-label="Error"
+ >
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="8" x2="12" y2="12"></line>
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
+ </svg>
+ {:else}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ aria-label="Idle"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {/if}
+ </span>
- {#if usage.percent !== null}
- <progress
- class="progress h-2 flex-1 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
- {/if}
+ {#if usage.percent !== null}
+ <progress
+ class="progress h-2 flex-1 {fillClass(usage.percent)}"
+ value={usage.percent}
+ max="100"
+ ></progress>
+ {:else}
+ <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
+ {/if}
- <span class="shrink-0 whitespace-nowrap font-mono">
- {#if hasUsage}
- {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
- class="text-base-content/40"
- >
- / {formatCompactTokens(usage.max)}</span
- >{/if}
- {#if usage.percent !== null}
- <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
- {/if}
- {:else}
- <span class="text-base-content/40">— tokens</span>
- {/if}
- </span>
- </div>
+ <span class="shrink-0 whitespace-nowrap font-mono">
+ {#if hasUsage}
+ {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
+ class="text-base-content/40"
+ >
+ / {formatCompactTokens(usage.max)}</span
+ >{/if}
+ {#if usage.percent !== null}
+ <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
+ {/if}
+ {:else}
+ <span class="text-base-content/40">— tokens</span>
+ {/if}
+ </span>
+ </div>
</form>
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index a288cb8..03acb79 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,50 +1,50 @@
<script lang="ts">
- import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
- let {
- models,
- selected,
- onSelect,
- }: {
- models: readonly string[];
- selected: string;
- onSelect: (model: string) => void;
- } = $props();
+ let {
+ models,
+ selected,
+ onSelect,
+ }: {
+ models: readonly string[];
+ selected: string;
+ onSelect: (model: string) => void;
+ } = $props();
- const keys = $derived(modelKeys(models));
- const current = $derived(splitModelName(selected));
- const keyModels = $derived(modelsForKey(models, current.key));
+ const keys = $derived(modelKeys(models));
+ const current = $derived(splitModelName(selected));
+ const keyModels = $derived(modelsForKey(models, current.key));
- // Switching key jumps to the first model available under it.
- function selectKey(key: string): void {
- const first = modelsForKey(models, key)[0] ?? "";
- onSelect(joinModelName(key, first));
- }
+ // Switching key jumps to the first model available under it.
+ function selectKey(key: string): void {
+ const first = modelsForKey(models, key)[0] ?? "";
+ onSelect(joinModelName(key, first));
+ }
- function selectModel(model: string): void {
- onSelect(joinModelName(current.key, model));
- }
+ function selectModel(model: string): void {
+ onSelect(joinModelName(current.key, model));
+ }
</script>
<div class="flex flex-col gap-2">
- <select
- class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
- aria-label="Key selector"
- >
- {#each keys as key (key)}
- <option value={key}>{key}</option>
- {/each}
- </select>
- <select
- class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
- aria-label="Model selector"
- >
- {#each keyModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
+ <select
+ class="select w-full"
+ value={current.key}
+ onchange={(e) => selectKey(e.currentTarget.value)}
+ aria-label="Key selector"
+ >
+ {#each keys as key (key)}
+ <option value={key}>{key}</option>
+ {/each}
+ </select>
+ <select
+ class="select w-full"
+ value={current.model}
+ onchange={(e) => selectModel(e.currentTarget.value)}
+ aria-label="Model selector"
+ >
+ {#each keyModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ </select>
</div>
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
index 8c7b193..d982905 100644
--- a/src/features/chat/ui/ReasoningEffortSelector.svelte
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -1,75 +1,75 @@
<script lang="ts">
- import type { ReasoningEffort } from "@dispatch/transport-contract";
- import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
- } from "../reasoning-effort";
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import {
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ type SaveReasoningEffort,
+ } from "../reasoning-effort";
- let {
- persisted,
- save,
- }: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
- } = $props();
+ let {
+ persisted,
+ save,
+ }: {
+ /** The conversation's persisted level, or null when never set (default applies). */
+ persisted: ReasoningEffort | null;
+ save: SaveReasoningEffort;
+ } = $props();
- const options = effortOptions();
+ const options = effortOptions();
- // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- let chosen = $state<ReasoningEffort | null>(null);
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
+ // Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ReasoningEffort | null>(null);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(chosen ?? effectiveEffort(persisted));
- async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
- chosen = value;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(value);
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- chosen = null; // revert to the persisted value
- }
- }
+ async function handleChange(value: string) {
+ if (!isReasoningEffort(value) || saving) return;
+ chosen = value;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(value);
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ chosen = null; // revert to the persisted value
+ }
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
- <div class="flex items-center gap-2">
- <select
- class="select select-sm w-full"
- value={selected}
- disabled={saving}
- onchange={(e) => handleChange(e.currentTarget.value)}
- aria-label="Reasoning effort"
- >
- {#each options as option (option.value)}
- <option value={option.value}>{option.label}</option>
- {/each}
- </select>
- {#if saving}
- <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span>
- {/if}
- </div>
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved}
- <p class="text-xs text-success">Saved — applies from the next turn.</p>
- {:else}
- <p class="text-xs opacity-50">
- How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
- </p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
+ <div class="flex items-center gap-2">
+ <select
+ class="select select-sm w-full"
+ value={selected}
+ disabled={saving}
+ onchange={(e) => handleChange(e.currentTarget.value)}
+ aria-label="Reasoning effort"
+ >
+ {#each options as option (option.value)}
+ <option value={option.value}>{option.label}</option>
+ {/each}
+ </select>
+ {#if saving}
+ <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span>
+ {/if}
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved}
+ <p class="text-xs text-success">Saved — applies from the next turn.</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
+ </p>
+ {/if}
</div>
diff --git a/src/features/computer/index.ts b/src/features/computer/index.ts
index 656f6c2..05e56cc 100644
--- a/src/features/computer/index.ts
+++ b/src/features/computer/index.ts
@@ -1,31 +1,31 @@
export type {
- Badge,
- ComputerListResult,
- ComputerSaveResult,
- ComputerStatusResult,
- ComputerStatusView,
- ComputerView,
- LoadComputerStatus,
- LoadComputers,
- SaveComputer,
- TestComputer,
- TestComputerResult,
- TestResultView,
+ Badge,
+ ComputerListResult,
+ ComputerSaveResult,
+ ComputerStatusResult,
+ ComputerStatusView,
+ ComputerView,
+ LoadComputerStatus,
+ LoadComputers,
+ SaveComputer,
+ TestComputer,
+ TestComputerResult,
+ TestResultView,
} from "./logic/view-model";
export {
- formatHost,
- knownHostLabel,
- summarizeComputers,
- viewComputer,
- viewComputerStatus,
- viewComputers,
- viewTestResult,
+ formatHost,
+ knownHostLabel,
+ summarizeComputers,
+ viewComputer,
+ viewComputerStatus,
+ viewComputers,
+ viewTestResult,
} from "./logic/view-model";
export { default as ComputerField } from "./ui/ComputerField.svelte";
export { default as ComputerSelect } from "./ui/ComputerSelect.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "computer",
- description: "Per-conversation / per-workspace SSH computer selection + status",
+ name: "computer",
+ description: "Per-conversation / per-workspace SSH computer selection + status",
} as const;
diff --git a/src/features/computer/logic/view-model.test.ts b/src/features/computer/logic/view-model.test.ts
index d9b7f74..45d459e 100644
--- a/src/features/computer/logic/view-model.test.ts
+++ b/src/features/computer/logic/view-model.test.ts
@@ -2,166 +2,166 @@ import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/tra
import type { ComputerEntry } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
- formatHost,
- knownHostLabel,
- summarizeComputers,
- viewComputer,
- viewComputerStatus,
- viewComputers,
- viewTestResult,
+ formatHost,
+ knownHostLabel,
+ summarizeComputers,
+ viewComputer,
+ viewComputerStatus,
+ viewComputers,
+ viewTestResult,
} from "./view-model";
function computer(overrides: Partial<ComputerEntry> = {}): ComputerEntry {
- return {
- alias: "buildbox",
- hostName: "10.0.0.5",
- port: 22,
- user: "deploy",
- identityFile: "/home/deploy/.ssh/id_ed25519",
- knownHost: true,
- usageCount: 0,
- ...overrides,
- };
+ return {
+ alias: "buildbox",
+ hostName: "10.0.0.5",
+ port: 22,
+ user: "deploy",
+ identityFile: "/home/deploy/.ssh/id_ed25519",
+ knownHost: true,
+ usageCount: 0,
+ ...overrides,
+ };
}
describe("formatHost", () => {
- it("is user@host with no port when port is the SSH default (22)", () => {
- expect(formatHost(computer({ port: 22 }))).toBe("[email protected]");
- });
+ it("is user@host with no port when port is the SSH default (22)", () => {
+ expect(formatHost(computer({ port: 22 }))).toBe("[email protected]");
+ });
- it("appends a non-default port", () => {
- expect(formatHost(computer({ port: 2222 }))).toBe("[email protected]:2222");
- });
+ it("appends a non-default port", () => {
+ expect(formatHost(computer({ port: 2222 }))).toBe("[email protected]:2222");
+ });
- it("falls back to the alias when hostName is empty", () => {
- expect(formatHost(computer({ hostName: "" }))).toBe("deploy@buildbox");
- });
+ it("falls back to the alias when hostName is empty", () => {
+ expect(formatHost(computer({ hostName: "" }))).toBe("deploy@buildbox");
+ });
});
describe("knownHostLabel", () => {
- it("is 'known host' when known", () => {
- expect(knownHostLabel(true)).toBe("known host");
- });
+ it("is 'known host' when known", () => {
+ expect(knownHostLabel(true)).toBe("known host");
+ });
- it("is 'new host' when not known", () => {
- expect(knownHostLabel(false)).toBe("new host");
- });
+ it("is 'new host' when not known", () => {
+ expect(knownHostLabel(false)).toBe("new host");
+ });
});
describe("viewComputer", () => {
- it("projects alias + hostSummary + identity + known-host label", () => {
- const v = viewComputer(computer());
- expect(v.alias).toBe("buildbox");
- expect(v.hostSummary).toBe("[email protected]");
- expect(v.identityFile).toBe("/home/deploy/.ssh/id_ed25519");
- expect(v.knownHostLabel).toBe("known host");
- expect(v.knownHost).toBe(true);
- });
-
- it("preserves a null identityFile (default key)", () => {
- const v = viewComputer(computer({ identityFile: null }));
- expect(v.identityFile).toBeNull();
- });
+ it("projects alias + hostSummary + identity + known-host label", () => {
+ const v = viewComputer(computer());
+ expect(v.alias).toBe("buildbox");
+ expect(v.hostSummary).toBe("[email protected]");
+ expect(v.identityFile).toBe("/home/deploy/.ssh/id_ed25519");
+ expect(v.knownHostLabel).toBe("known host");
+ expect(v.knownHost).toBe(true);
+ });
+
+ it("preserves a null identityFile (default key)", () => {
+ const v = viewComputer(computer({ identityFile: null }));
+ expect(v.identityFile).toBeNull();
+ });
});
describe("viewComputers", () => {
- it("maps each entry (and drops usageCount from the view)", () => {
- const views = viewComputers([
- computer({ alias: "a", usageCount: 3 }),
- computer({ alias: "b", hostName: "b.local", usageCount: 0 }),
- ]);
- expect(views).toHaveLength(2);
- expect(views.at(0)?.alias).toBe("a");
- expect(views.at(1)?.hostSummary).toBe("[email protected]");
- });
+ it("maps each entry (and drops usageCount from the view)", () => {
+ const views = viewComputers([
+ computer({ alias: "a", usageCount: 3 }),
+ computer({ alias: "b", hostName: "b.local", usageCount: 0 }),
+ ]);
+ expect(views).toHaveLength(2);
+ expect(views.at(0)?.alias).toBe("a");
+ expect(views.at(1)?.hostSummary).toBe("[email protected]");
+ });
});
describe("summarizeComputers", () => {
- it("is 'No computers discovered' for an empty list", () => {
- expect(summarizeComputers([])).toBe("No computers discovered");
- });
+ it("is 'No computers discovered' for an empty list", () => {
+ expect(summarizeComputers([])).toBe("No computers discovered");
+ });
- it("is singular for one computer", () => {
- expect(summarizeComputers([computer()])).toBe("1 computer");
- });
+ it("is singular for one computer", () => {
+ expect(summarizeComputers([computer()])).toBe("1 computer");
+ });
- it("is plural for many", () => {
- expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers");
- });
+ it("is plural for many", () => {
+ expect(summarizeComputers([computer(), computer(), computer()])).toBe("3 computers");
+ });
});
describe("viewComputerStatus", () => {
- function status(
- state: ComputerStatusResponse["state"],
- overrides: Partial<ComputerStatusResponse> = {},
- ): ComputerStatusResponse {
- return { alias: "buildbox", state, knownHost: true, ...overrides };
- }
-
- it("connected → success badge, not busy, no error", () => {
- const v = viewComputerStatus(status("connected"));
- expect(v.statusLabel).toBe("Connected");
- expect(v.badge).toBe("success");
- expect(v.busy).toBe(false);
- expect(v.error).toBeNull();
- });
-
- it("connecting → warning badge + busy (spinner), no error", () => {
- const v = viewComputerStatus(status("connecting"));
- expect(v.statusLabel).toBe("Connecting…");
- expect(v.badge).toBe("warning");
- expect(v.busy).toBe(true);
- expect(v.error).toBeNull();
- });
-
- it("disconnected → neutral badge, NOT busy (a stable idle state)", () => {
- const v = viewComputerStatus(status("disconnected"));
- expect(v.statusLabel).toBe("Disconnected");
- expect(v.badge).toBe("neutral");
- expect(v.busy).toBe(false);
- expect(v.error).toBeNull();
- });
-
- it("error → error badge, not busy, surfaces the reason", () => {
- const v = viewComputerStatus(status("error", { error: "auth refused" }));
- expect(v.statusLabel).toBe("Error");
- expect(v.badge).toBe("error");
- expect(v.busy).toBe(false);
- expect(v.error).toBe("auth refused");
- });
-
- it("error falls back to a default reason when the backend omits one", () => {
- const v = viewComputerStatus(status("error"));
- expect(v.error).toBe("Connection failed");
- });
-
- it("carries the knownHost flag through", () => {
- const v = viewComputerStatus(status("connected", { knownHost: false }));
- expect(v.knownHost).toBe(false);
- });
+ function status(
+ state: ComputerStatusResponse["state"],
+ overrides: Partial<ComputerStatusResponse> = {},
+ ): ComputerStatusResponse {
+ return { alias: "buildbox", state, knownHost: true, ...overrides };
+ }
+
+ it("connected → success badge, not busy, no error", () => {
+ const v = viewComputerStatus(status("connected"));
+ expect(v.statusLabel).toBe("Connected");
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ });
+
+ it("connecting → warning badge + busy (spinner), no error", () => {
+ const v = viewComputerStatus(status("connecting"));
+ expect(v.statusLabel).toBe("Connecting…");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ expect(v.error).toBeNull();
+ });
+
+ it("disconnected → neutral badge, NOT busy (a stable idle state)", () => {
+ const v = viewComputerStatus(status("disconnected"));
+ expect(v.statusLabel).toBe("Disconnected");
+ expect(v.badge).toBe("neutral");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ });
+
+ it("error → error badge, not busy, surfaces the reason", () => {
+ const v = viewComputerStatus(status("error", { error: "auth refused" }));
+ expect(v.statusLabel).toBe("Error");
+ expect(v.badge).toBe("error");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBe("auth refused");
+ });
+
+ it("error falls back to a default reason when the backend omits one", () => {
+ const v = viewComputerStatus(status("error"));
+ expect(v.error).toBe("Connection failed");
+ });
+
+ it("carries the knownHost flag through", () => {
+ const v = viewComputerStatus(status("connected", { knownHost: false }));
+ expect(v.knownHost).toBe(false);
+ });
});
describe("viewTestResult", () => {
- it("ok=true → no error, 'Connection OK' label", () => {
- const r = viewTestResult({ alias: "buildbox", ok: true } as TestComputerResponse);
- expect(r.ok).toBe(true);
- expect(r.error).toBeNull();
- expect(r.label).toBe("Connection OK");
- });
-
- it("ok=false → surfaces the failure reason", () => {
- const r = viewTestResult({
- alias: "buildbox",
- ok: false,
- error: "host unreachable",
- } as TestComputerResponse);
- expect(r.ok).toBe(false);
- expect(r.error).toBe("host unreachable");
- expect(r.label).toBe("Failed");
- });
-
- it("ok=false without a reason falls back to a default", () => {
- const r = viewTestResult({ alias: "buildbox", ok: false } as TestComputerResponse);
- expect(r.error).toBe("Connection failed");
- });
+ it("ok=true → no error, 'Connection OK' label", () => {
+ const r = viewTestResult({ alias: "buildbox", ok: true } as TestComputerResponse);
+ expect(r.ok).toBe(true);
+ expect(r.error).toBeNull();
+ expect(r.label).toBe("Connection OK");
+ });
+
+ it("ok=false → surfaces the failure reason", () => {
+ const r = viewTestResult({
+ alias: "buildbox",
+ ok: false,
+ error: "host unreachable",
+ } as TestComputerResponse);
+ expect(r.ok).toBe(false);
+ expect(r.error).toBe("host unreachable");
+ expect(r.label).toBe("Failed");
+ });
+
+ it("ok=false without a reason falls back to a default", () => {
+ const r = viewTestResult({ alias: "buildbox", ok: false } as TestComputerResponse);
+ expect(r.error).toBe("Connection failed");
+ });
});
diff --git a/src/features/computer/logic/view-model.ts b/src/features/computer/logic/view-model.ts
index 944456b..d489fb5 100644
--- a/src/features/computer/logic/view-model.ts
+++ b/src/features/computer/logic/view-model.ts
@@ -24,29 +24,29 @@ import type { Computer, ComputerEntry } from "@dispatch/wire";
/** Outcome of `PUT /conversations/:id/computer`; `null` when no real conversation is focused. */
export type ComputerSaveResult =
- | { readonly ok: true; readonly computerId: string | null }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly computerId: string | null }
+ | { readonly ok: false; readonly error: string };
export type SaveComputer = (computerId: string | null) => Promise<ComputerSaveResult | null>;
/** Outcome of `GET /computers`; `null` when the list couldn't be loaded. */
export type ComputerListResult =
- | { readonly ok: true; readonly computers: readonly ComputerEntry[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly computers: readonly ComputerEntry[] }
+ | { readonly ok: false; readonly error: string };
export type LoadComputers = () => Promise<ComputerListResult | null>;
/** Outcome of `GET /computers/:alias/status`; `null` when no alias / not loaded. */
export type ComputerStatusResult =
- | { readonly ok: true; readonly status: ComputerStatusResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly status: ComputerStatusResponse }
+ | { readonly ok: false; readonly error: string };
export type LoadComputerStatus = (alias: string) => Promise<ComputerStatusResult | null>;
/** Outcome of `POST /computers/:alias/test`. */
export type TestComputerResult =
- | { readonly ok: true; readonly response: TestComputerResponse }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly response: TestComputerResponse }
+ | { readonly ok: false; readonly error: string };
export type TestComputer = (alias: string) => Promise<TestComputerResult | null>;
@@ -55,16 +55,16 @@ export type TestComputer = (alias: string) => Promise<TestComputerResult | null>
export type Badge = "success" | "warning" | "error" | "neutral";
export interface ComputerView {
- /** The SSH config `Host` alias — also the `computerId` users select. */
- readonly alias: string;
- /** A compact `user@host:port` connection string (the resolved config values). */
- readonly hostSummary: string;
- /** The resolved `IdentityFile`, or `null` = default `~/.ssh/id_*`. */
- readonly identityFile: string | null;
- /** Short label for the known-host indicator, e.g. "known host" / "new host". */
- readonly knownHostLabel: string;
- /** Whether the host's key is already in `~/.ssh/known_hosts`. */
- readonly knownHost: boolean;
+ /** The SSH config `Host` alias — also the `computerId` users select. */
+ readonly alias: string;
+ /** A compact `user@host:port` connection string (the resolved config values). */
+ readonly hostSummary: string;
+ /** The resolved `IdentityFile`, or `null` = default `~/.ssh/id_*`. */
+ readonly identityFile: string | null;
+ /** Short label for the known-host indicator, e.g. "known host" / "new host". */
+ readonly knownHostLabel: string;
+ /** Whether the host's key is already in `~/.ssh/known_hosts`. */
+ readonly knownHost: boolean;
}
/**
@@ -74,28 +74,28 @@ export interface ComputerView {
* (the backend resolves this, but this is defensive).
*/
export function formatHost(computer: Computer): string {
- const host = computer.hostName || computer.alias;
- const port = computer.port === 22 ? "" : `:${computer.port}`;
- return `${computer.user}@${host}${port}`;
+ const host = computer.hostName || computer.alias;
+ const port = computer.port === 22 ? "" : `:${computer.port}`;
+ return `${computer.user}@${host}${port}`;
}
/** The display label for the known-host indicator. */
export function knownHostLabel(knownHost: boolean): string {
- return knownHost ? "known host" : "new host";
+ return knownHost ? "known host" : "new host";
}
export function viewComputer(computer: Computer): ComputerView {
- return {
- alias: computer.alias,
- hostSummary: formatHost(computer),
- identityFile: computer.identityFile,
- knownHostLabel: knownHostLabel(computer.knownHost),
- knownHost: computer.knownHost,
- };
+ return {
+ alias: computer.alias,
+ hostSummary: formatHost(computer),
+ identityFile: computer.identityFile,
+ knownHostLabel: knownHostLabel(computer.knownHost),
+ knownHost: computer.knownHost,
+ };
}
export function viewComputers(computers: readonly ComputerEntry[]): readonly ComputerView[] {
- return computers.map(viewComputer);
+ return computers.map(viewComputer);
}
/**
@@ -103,23 +103,23 @@ export function viewComputers(computers: readonly ComputerEntry[]): readonly Com
* discovered" (the latter is the expected state until the `ssh` extension lands).
*/
export function summarizeComputers(computers: readonly ComputerEntry[]): string {
- if (computers.length === 0) return "No computers discovered";
- return `${computers.length} computer${computers.length === 1 ? "" : "s"}`;
+ if (computers.length === 0) return "No computers discovered";
+ return `${computers.length} computer${computers.length === 1 ? "" : "s"}`;
}
// ── Connection status → display view ──────────────────────────────────────────
export interface ComputerStatusView {
- readonly alias: string;
- readonly state: ComputerStatusResponse["state"];
- readonly statusLabel: string;
- readonly badge: Badge;
- /** True while the state is transient (show a spinner). */
- readonly busy: boolean;
- /** The error reason when `state === "error"`, else null. */
- readonly error: string | null;
- /** Whether the host's key is already in `~/.ssh/known_hosts`. */
- readonly knownHost: boolean;
+ readonly alias: string;
+ readonly state: ComputerStatusResponse["state"];
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the state is transient (show a spinner). */
+ readonly busy: boolean;
+ /** The error reason when `state === "error"`, else null. */
+ readonly error: string | null;
+ /** Whether the host's key is already in `~/.ssh/known_hosts`. */
+ readonly knownHost: boolean;
}
/**
@@ -130,55 +130,55 @@ export interface ComputerStatusView {
* idle state, NOT busy), `error`→error.
*/
export function viewComputerStatus(status: ComputerStatusResponse): ComputerStatusView {
- let statusLabel: string;
- let badge: Badge;
- let busy = false;
- switch (status.state) {
- case "connected":
- statusLabel = "Connected";
- badge = "success";
- break;
- case "connecting":
- statusLabel = "Connecting…";
- badge = "warning";
- busy = true;
- break;
- case "disconnected":
- statusLabel = "Disconnected";
- badge = "neutral";
- break;
- case "error":
- statusLabel = "Error";
- badge = "error";
- break;
- }
- return {
- alias: status.alias,
- state: status.state,
- statusLabel,
- badge,
- busy,
- error: status.state === "error" ? (status.error ?? "Connection failed") : null,
- knownHost: status.knownHost,
- };
+ let statusLabel: string;
+ let badge: Badge;
+ let busy = false;
+ switch (status.state) {
+ case "connected":
+ statusLabel = "Connected";
+ badge = "success";
+ break;
+ case "connecting":
+ statusLabel = "Connecting…";
+ badge = "warning";
+ busy = true;
+ break;
+ case "disconnected":
+ statusLabel = "Disconnected";
+ badge = "neutral";
+ break;
+ case "error":
+ statusLabel = "Error";
+ badge = "error";
+ break;
+ }
+ return {
+ alias: status.alias,
+ state: status.state,
+ statusLabel,
+ badge,
+ busy,
+ error: status.state === "error" ? (status.error ?? "Connection failed") : null,
+ knownHost: status.knownHost,
+ };
}
// ── Test-connection result → display view ───────────────────────────────────
export interface TestResultView {
- readonly alias: string;
- readonly ok: boolean;
- /** The failure reason when `ok` is false, else null. */
- readonly error: string | null;
- /** A short result label, e.g. "Connection OK" / "Failed". */
- readonly label: string;
+ readonly alias: string;
+ readonly ok: boolean;
+ /** The failure reason when `ok` is false, else null. */
+ readonly error: string | null;
+ /** A short result label, e.g. "Connection OK" / "Failed". */
+ readonly label: string;
}
export function viewTestResult(response: TestComputerResponse): TestResultView {
- return {
- alias: response.alias,
- ok: response.ok,
- error: response.ok ? null : (response.error ?? "Connection failed"),
- label: response.ok ? "Connection OK" : "Failed",
- };
+ return {
+ alias: response.alias,
+ ok: response.ok,
+ error: response.ok ? null : (response.error ?? "Connection failed"),
+ label: response.ok ? "Connection OK" : "Failed",
+ };
}
diff --git a/src/features/computer/ui/ComputerField.svelte b/src/features/computer/ui/ComputerField.svelte
index ce19b4d..6b54c88 100644
--- a/src/features/computer/ui/ComputerField.svelte
+++ b/src/features/computer/ui/ComputerField.svelte
@@ -1,209 +1,211 @@
<script lang="ts">
- import type { ComputerStatusResponse } from "@dispatch/transport-contract";
- import ComputerSelect from "./ComputerSelect.svelte";
- import {
- viewComputerStatus,
- type ComputerStatusView,
- type LoadComputerStatus,
- type SaveComputer,
- type TestComputer,
-} from "../logic/view-model";
- import type { ComputerEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
-
- let {
- computerId,
- canEdit,
- computers,
- save,
- loadStatus,
- test,
- }: {
- /** The active conversation's persisted computer alias, or null (local/inherit). */
- computerId: string | null;
- /** Whether a real conversation is focused (a draft can't persist yet). */
- canEdit: boolean;
- /** Discovered computers from `GET /computers` (read-only). */
- computers: readonly ComputerEntry[];
- save: SaveComputer;
- loadStatus: LoadComputerStatus;
- test: TestComputer;
- } = $props();
-
- // ── Save: selecting a dropdown option persists immediately (PUT /computer). ──
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
-
- async function select(computerId: string | null) {
- if (saving || !canEdit) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(computerId);
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- }
- }
-
- // ── Connection status: poll the selected computer's live state. Owned + ──
- // disposed here (never leaks across conversations — the field re-mounts per
- // conversation via the {#key} in App.svelte, like CwdField). No poll while
- // local (no alias) or while a draft can't persist.
- let statusView = $state<ComputerStatusView | null>(null);
- let statusError = $state<string | null>(null);
-
- async function refreshStatus() {
- const alias = untrack(() => computerId);
- if (alias === null) {
- statusView = null;
- statusError = null;
- return;
- }
- const result = await loadStatus(alias);
- if (result === null) return;
- if (result.ok) {
- statusView = viewComputerStatus(result.status);
- statusError = null;
- } else {
- statusView = null;
- statusError = result.error;
- }
- }
-
- // Re-fetch on mount + whenever the selected alias changes (incl. after a save).
- // Clear the test result so a stale ✓ from alias A doesn't persist for alias B.
- $effect(() => {
- void computerId;
- testResult = null;
- void refreshStatus();
- });
-
- // Poll the status while a computer is selected. `connecting` is transient, so
- // a faster cadence helps it flip to `connected`; a connected host is stable.
- const POLL_MS = 4000;
- $effect(() => {
- const alias = computerId;
- if (alias === null) return;
- const handle = setInterval(() => void refreshStatus(), POLL_MS);
- return () => clearInterval(handle);
- });
-
- // ── Test connection: one-shot probe (POST /computers/:alias/test). ──────────
- let testing = $state(false);
- let testResult = $state<{ ok: boolean; error: string | null } | null>(null);
-
- async function runTest() {
- const alias = untrack(() => computerId);
- if (alias === null || testing) return;
- testing = true;
- testResult = null;
- const result = await test(alias);
- testing = false;
- if (result === null) return;
- testResult = result.ok
- ? { ok: true, error: null }
- : { ok: false, error: result.error };
- // Refresh the connection-status badge so it reflects the post-test state
- // (clears a stale "connecting" spinner caught by the poll mid-test).
- void refreshStatus();
- }
-
- const badgeClass = $derived.by(() => {
- const b = statusView?.badge ?? "neutral";
- switch (b) {
- case "success":
- return "badge-success";
- case "warning":
- return "badge-warning";
- case "error":
- return "badge-error";
- default:
- return "badge-ghost";
- }
- });
+ import type { ComputerStatusResponse } from "@dispatch/transport-contract";
+ import ComputerSelect from "./ComputerSelect.svelte";
+ import {
+ viewComputerStatus,
+ type ComputerStatusView,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ } from "../logic/view-model";
+ import type { ComputerEntry } from "@dispatch/wire";
+ import { untrack } from "svelte";
+
+ let {
+ computerId,
+ canEdit,
+ computers,
+ save,
+ loadStatus,
+ test,
+ }: {
+ /** The active conversation's persisted computer alias, or null (local/inherit). */
+ computerId: string | null;
+ /** Whether a real conversation is focused (a draft can't persist yet). */
+ canEdit: boolean;
+ /** Discovered computers from `GET /computers` (read-only). */
+ computers: readonly ComputerEntry[];
+ save: SaveComputer;
+ loadStatus: LoadComputerStatus;
+ test: TestComputer;
+ } = $props();
+
+ // ── Save: selecting a dropdown option persists immediately (PUT /computer). ──
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+
+ async function select(computerId: string | null) {
+ if (saving || !canEdit) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(computerId);
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
+
+ // ── Connection status: poll the selected computer's live state. Owned + ──
+ // disposed here (never leaks across conversations — the field re-mounts per
+ // conversation via the {#key} in App.svelte, like CwdField). No poll while
+ // local (no alias) or while a draft can't persist.
+ let statusView = $state<ComputerStatusView | null>(null);
+ let statusError = $state<string | null>(null);
+
+ async function refreshStatus() {
+ const alias = untrack(() => computerId);
+ if (alias === null) {
+ statusView = null;
+ statusError = null;
+ return;
+ }
+ const result = await loadStatus(alias);
+ if (result === null) return;
+ if (result.ok) {
+ statusView = viewComputerStatus(result.status);
+ statusError = null;
+ } else {
+ statusView = null;
+ statusError = result.error;
+ }
+ }
+
+ // Re-fetch on mount + whenever the selected alias changes (incl. after a save).
+ // Clear the test result so a stale ✓ from alias A doesn't persist for alias B.
+ $effect(() => {
+ void computerId;
+ testResult = null;
+ void refreshStatus();
+ });
+
+ // Poll the status while a computer is selected. `connecting` is transient, so
+ // a faster cadence helps it flip to `connected`; a connected host is stable.
+ const POLL_MS = 4000;
+ $effect(() => {
+ const alias = computerId;
+ if (alias === null) return;
+ const handle = setInterval(() => void refreshStatus(), POLL_MS);
+ return () => clearInterval(handle);
+ });
+
+ // ── Test connection: one-shot probe (POST /computers/:alias/test). ──────────
+ let testing = $state(false);
+ let testResult = $state<{ ok: boolean; error: string | null } | null>(null);
+
+ async function runTest() {
+ const alias = untrack(() => computerId);
+ if (alias === null || testing) return;
+ testing = true;
+ testResult = null;
+ const result = await test(alias);
+ testing = false;
+ if (result === null) return;
+ testResult = result.ok ? { ok: true, error: null } : { ok: false, error: result.error };
+ // Refresh the connection-status badge so it reflects the post-test state
+ // (clears a stale "connecting" spinner caught by the poll mid-test).
+ void refreshStatus();
+ }
+
+ const badgeClass = $derived.by(() => {
+ const b = statusView?.badge ?? "neutral";
+ switch (b) {
+ case "success":
+ return "badge-success";
+ case "warning":
+ return "badge-warning";
+ case "error":
+ return "badge-error";
+ default:
+ return "badge-ghost";
+ }
+ });
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Computer (SSH)</span>
- <div class="flex items-center gap-2">
- <ComputerSelect
- value={computerId}
- {computers}
- disabled={!canEdit || saving}
- onSelect={select}
- />
- {#if saving}
- <span class="loading loading-spinner loading-xs shrink-0"></span>
- {/if}
- </div>
-
- {#if !canEdit}
- <p class="text-xs opacity-60">Start or open a conversation to set its computer.</p>
- {:else if computerId !== null}
- <!-- Connection status badge + Test affordance for the selected computer. -->
- <div class="flex flex-wrap items-center gap-2">
- {#if statusView}
- <span class="badge badge-sm {badgeClass}" title={statusView.error ?? statusView.statusLabel}>
- {#if statusView.busy}
- <span class="loading loading-spinner loading-[10px]"></span>
- {/if}
- {statusView.statusLabel}
- </span>
- {:else if statusError}
- <span class="badge badge-sm badge-error" title={statusError}>Status error</span>
- {:else}
- <span class="badge badge-sm badge-ghost">—</span>
- {/if}
-
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={testing}
- onclick={runTest}
- title={testResult
- ? testResult.ok
- ? "Connection OK"
- : testResult.error ?? "Failed"
- : "Test connection"}
- >
- {#if testing}
- <span class="loading loading-spinner loading-[10px]"></span>
- {:else if testResult?.ok}
- <span class="text-success">✓</span>
- {:else if testResult}
- <span class="text-error">✗</span>
- {:else}
- Test
- {/if}
- </button>
-
- {#if testResult}
- <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
- {testResult.ok ? "OK" : testResult.error ?? "Failed"}
- </span>
- {/if}
- </div>
- {#if statusView?.error}
- <p class="text-xs text-error">{statusView.error}</p>
- {/if}
- {:else if computers.length === 0}
- <p class="text-xs opacity-50">
- No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer.
- </p>
- {:else if justSaved && !error}
- <p class="text-xs text-success">Saved.</p>
- {/if}
-
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {/if}
-
- <p class="text-xs opacity-50">
- Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH. Not seen by the agent.
- </p>
+ <span class="text-xs font-semibold uppercase opacity-60">Computer (SSH)</span>
+ <div class="flex items-center gap-2">
+ <ComputerSelect
+ value={computerId}
+ {computers}
+ disabled={!canEdit || saving}
+ onSelect={select}
+ />
+ {#if saving}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ {#if !canEdit}
+ <p class="text-xs opacity-60">Start or open a conversation to set its computer.</p>
+ {:else if computerId !== null}
+ <!-- Connection status badge + Test affordance for the selected computer. -->
+ <div class="flex flex-wrap items-center gap-2">
+ {#if statusView}
+ <span
+ class="badge badge-sm {badgeClass}"
+ title={statusView.error ?? statusView.statusLabel}
+ >
+ {#if statusView.busy}
+ <span class="loading loading-spinner loading-[10px]"></span>
+ {/if}
+ {statusView.statusLabel}
+ </span>
+ {:else if statusError}
+ <span class="badge badge-sm badge-error" title={statusError}>Status error</span>
+ {:else}
+ <span class="badge badge-sm badge-ghost">—</span>
+ {/if}
+
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={testing}
+ onclick={runTest}
+ title={testResult
+ ? testResult.ok
+ ? "Connection OK"
+ : (testResult.error ?? "Failed")
+ : "Test connection"}
+ >
+ {#if testing}
+ <span class="loading loading-spinner loading-[10px]"></span>
+ {:else if testResult?.ok}
+ <span class="text-success">✓</span>
+ {:else if testResult}
+ <span class="text-error">✗</span>
+ {:else}
+ Test
+ {/if}
+ </button>
+
+ {#if testResult}
+ <span class="text-xs {testResult.ok ? 'text-success' : 'text-error'}">
+ {testResult.ok ? "OK" : (testResult.error ?? "Failed")}
+ </span>
+ {/if}
+ </div>
+ {#if statusView?.error}
+ <p class="text-xs text-error">{statusView.error}</p>
+ {/if}
+ {:else if computers.length === 0}
+ <p class="text-xs opacity-50">
+ No computers discovered — add a `Host` block to your `~/.ssh/config` to use a remote computer.
+ </p>
+ {:else if justSaved && !error}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {/if}
+
+ <p class="text-xs opacity-50">
+ Where this conversation's tools run. `null` (Local) runs on the server; an alias runs over SSH.
+ Not seen by the agent.
+ </p>
</div>
diff --git a/src/features/computer/ui/ComputerSelect.svelte b/src/features/computer/ui/ComputerSelect.svelte
index d693140..c580b60 100644
--- a/src/features/computer/ui/ComputerSelect.svelte
+++ b/src/features/computer/ui/ComputerSelect.svelte
@@ -1,39 +1,39 @@
<script lang="ts">
- import type { ComputerEntry } from "@dispatch/wire";
+ import type { ComputerEntry } from "@dispatch/wire";
- let {
- value,
- computers,
- disabled = false,
- onSelect,
- }: {
- /** The currently selected computer alias, or null for "Local (none)". */
- value: string | null;
- /** Discovered computers from `GET /computers` (read-only). */
- computers: readonly ComputerEntry[];
- disabled?: boolean;
- onSelect: (computerId: string | null) => void;
- } = $props();
+ let {
+ value,
+ computers,
+ disabled = false,
+ onSelect,
+ }: {
+ /** The currently selected computer alias, or null for "Local (none)". */
+ value: string | null;
+ /** Discovered computers from `GET /computers` (read-only). */
+ computers: readonly ComputerEntry[];
+ disabled?: boolean;
+ onSelect: (computerId: string | null) => void;
+ } = $props();
- // A `<select>` value is a string; map "" ↔ null (Local). The chosen option's
- // value is the alias, with "" meaning "clear / local".
- const selectValue = $derived(value ?? "");
+ // A `<select>` value is a string; map "" ↔ null (Local). The chosen option's
+ // value is the alias, with "" meaning "clear / local".
+ const selectValue = $derived(value ?? "");
- function onChange(e: Event) {
- const v = (e.currentTarget as HTMLSelectElement).value;
- onSelect(v === "" ? null : v);
- }
+ function onChange(e: Event) {
+ const v = (e.currentTarget as HTMLSelectElement).value;
+ onSelect(v === "" ? null : v);
+ }
</script>
<select
- class="select select-bordered select-sm w-full font-mono text-xs"
- value={selectValue}
- disabled={disabled}
- onchange={onChange}
- aria-label="Computer"
+ class="select select-bordered select-sm w-full font-mono text-xs"
+ value={selectValue}
+ {disabled}
+ onchange={onChange}
+ aria-label="Computer"
>
- <option value="">Local (none)</option>
- {#each computers as c (c.alias)}
- <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option>
- {/each}
+ <option value="">Local (none)</option>
+ {#each computers as c (c.alias)}
+ <option value={c.alias}>{c.alias}{c.knownHost ? "" : " · new host"}</option>
+ {/each}
</select>
diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts
index 89e81b8..ce1c60c 100644
--- a/src/features/conversation-cache/cache.test.ts
+++ b/src/features/conversation-cache/cache.test.ts
@@ -4,9 +4,9 @@ import { createConversationCache } from "./cache";
import type { ConversationCacheIndexEntry, ConversationChunkStore } from "./types";
const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({
- seq,
- role,
- chunk: { type: "text", text: `chunk-${seq}` },
+ seq,
+ role,
+ chunk: { type: "text", text: `chunk-${seq}` },
});
/**
@@ -14,184 +14,184 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk =>
* An outermost edge: simulates the storage port without any real I/O.
*/
function createFakeStore(): ConversationChunkStore {
- const store = new Map<string, StoredChunk[]>();
-
- return {
- async load(conversationId) {
- return store.get(conversationId) ?? [];
- },
-
- async append(conversationId, chunks) {
- const existing = store.get(conversationId) ?? [];
- const existingSeqs = new Set(existing.map((c) => c.seq));
- const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq));
- store.set(
- conversationId,
- [...existing, ...toAdd].sort((a, b) => a.seq - b.seq),
- );
- },
-
- async delete(conversationId) {
- store.delete(conversationId);
- },
-
- async index() {
- const entries: ConversationCacheIndexEntry[] = [];
- for (const [id, chunks] of store) {
- if (chunks.length === 0) continue;
- let maxSeq = 0;
- for (const c of chunks) {
- if (c.seq > maxSeq) maxSeq = c.seq;
- }
- entries.push({
- conversationId: id,
- chunkCount: chunks.length,
- maxSeq,
- });
- }
- return entries;
- },
- };
+ const store = new Map<string, StoredChunk[]>();
+
+ return {
+ async load(conversationId) {
+ return store.get(conversationId) ?? [];
+ },
+
+ async append(conversationId, chunks) {
+ const existing = store.get(conversationId) ?? [];
+ const existingSeqs = new Set(existing.map((c) => c.seq));
+ const toAdd = chunks.filter((c) => !existingSeqs.has(c.seq));
+ store.set(
+ conversationId,
+ [...existing, ...toAdd].sort((a, b) => a.seq - b.seq),
+ );
+ },
+
+ async delete(conversationId) {
+ store.delete(conversationId);
+ },
+
+ async index() {
+ const entries: ConversationCacheIndexEntry[] = [];
+ for (const [id, chunks] of store) {
+ if (chunks.length === 0) continue;
+ let maxSeq = 0;
+ for (const c of chunks) {
+ if (c.seq > maxSeq) maxSeq = c.seq;
+ }
+ entries.push({
+ conversationId: id,
+ chunkCount: chunks.length,
+ maxSeq,
+ });
+ }
+ return entries;
+ },
+ };
}
describe("cache.load", () => {
- it("returns stored chunks", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(2)]);
- const result = await cache.load("conv-1");
- expect(result).toEqual([chunk(1), chunk(2)]);
- });
-
- it("returns empty array for absent conversation", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- const result = await cache.load("nonexistent");
- expect(result).toEqual([]);
- });
+ it("returns stored chunks", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+ const result = await cache.load("conv-1");
+ expect(result).toEqual([chunk(1), chunk(2)]);
+ });
+
+ it("returns empty array for absent conversation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ const result = await cache.load("nonexistent");
+ expect(result).toEqual([]);
+ });
});
describe("cache.commit", () => {
- it("appends only new chunks", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(2)]);
-
- const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]);
- expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]);
-
- // Verify store has all chunks
- const stored = await store.load("conv-1");
- expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]);
- });
-
- it("returns full merged result", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
-
- const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]);
- expect(merged).toEqual([chunk(1), chunk(3)]);
- });
-
- it("is idempotent — re-committing same chunks is a no-op", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
-
- await cache.commit("conv-1", [chunk(1), chunk(2)]);
- const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]);
- expect(merged).toEqual([chunk(1), chunk(2)]);
-
- const stored = await store.load("conv-1");
- expect(stored).toEqual([chunk(1), chunk(2)]);
- });
+ it("appends only new chunks", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+
+ const merged = await cache.commit("conv-1", [chunk(2), chunk(3)]);
+ expect(merged).toEqual([chunk(1), chunk(2), chunk(3)]);
+
+ // Verify store has all chunks
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([chunk(1), chunk(2), chunk(3)]);
+ });
+
+ it("returns full merged result", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+
+ const merged = await cache.commit("conv-1", [chunk(3), chunk(1)]);
+ expect(merged).toEqual([chunk(1), chunk(3)]);
+ });
+
+ it("is idempotent — re-committing same chunks is a no-op", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+
+ await cache.commit("conv-1", [chunk(1), chunk(2)]);
+ const merged = await cache.commit("conv-1", [chunk(1), chunk(2)]);
+ expect(merged).toEqual([chunk(1), chunk(2)]);
+
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([chunk(1), chunk(2)]);
+ });
});
describe("cache.sinceSeq", () => {
- it("returns max seq from cache", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]);
- expect(await cache.sinceSeq("conv-1")).toBe(5);
- });
-
- it("returns 0 for empty conversation", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
- expect(await cache.sinceSeq("conv-1")).toBe(0);
- });
+ it("returns max seq from cache", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ await store.append("conv-1", [chunk(1), chunk(5), chunk(3)]);
+ expect(await cache.sinceSeq("conv-1")).toBe(5);
+ });
+
+ it("returns 0 for empty conversation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
+ expect(await cache.sinceSeq("conv-1")).toBe(0);
+ });
});
describe("cache.evictIfOverBudget", () => {
- it("deletes selected conversations", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store, { maxChunks: 5 });
-
- await store.append("a", [chunk(1), chunk(2)]);
- await store.append("b", [chunk(1), chunk(2)]);
- await store.append("c", [chunk(1)]);
-
- // Total = 5, max = 5, under budget
- const evicted = await cache.evictIfOverBudget(null);
- expect(evicted).toEqual([]);
-
- // Add more to go over budget
- await store.append("d", [chunk(1), chunk(2), chunk(3)]);
- // Total = 8, max = 5, need to evict 3+ chunks
-
- const evicted2 = await cache.evictIfOverBudget(null);
- expect(evicted2.length).toBeGreaterThan(0);
-
- // Verify evicted conversations are deleted
- for (const id of evicted2) {
- expect(await store.load(id)).toEqual([]);
- }
- });
-
- it("never evicts the active conversation", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store, { maxChunks: 3 });
-
- await store.append("active", [chunk(1), chunk(2), chunk(3)]);
- await store.append("other", [chunk(1), chunk(2)]);
-
- // Total = 5, max = 3, need to evict 2+ chunks
- const evicted = await cache.evictIfOverBudget("active");
- expect(evicted).not.toContain("active");
- expect(evicted).toContain("other");
- });
-
- it("returns empty when under budget", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store, { maxChunks: 100 });
-
- await store.append("a", [chunk(1)]);
- await store.append("b", [chunk(1)]);
-
- const evicted = await cache.evictIfOverBudget(null);
- expect(evicted).toEqual([]);
- });
+ it("deletes selected conversations", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store, { maxChunks: 5 });
+
+ await store.append("a", [chunk(1), chunk(2)]);
+ await store.append("b", [chunk(1), chunk(2)]);
+ await store.append("c", [chunk(1)]);
+
+ // Total = 5, max = 5, under budget
+ const evicted = await cache.evictIfOverBudget(null);
+ expect(evicted).toEqual([]);
+
+ // Add more to go over budget
+ await store.append("d", [chunk(1), chunk(2), chunk(3)]);
+ // Total = 8, max = 5, need to evict 3+ chunks
+
+ const evicted2 = await cache.evictIfOverBudget(null);
+ expect(evicted2.length).toBeGreaterThan(0);
+
+ // Verify evicted conversations are deleted
+ for (const id of evicted2) {
+ expect(await store.load(id)).toEqual([]);
+ }
+ });
+
+ it("never evicts the active conversation", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store, { maxChunks: 3 });
+
+ await store.append("active", [chunk(1), chunk(2), chunk(3)]);
+ await store.append("other", [chunk(1), chunk(2)]);
+
+ // Total = 5, max = 3, need to evict 2+ chunks
+ const evicted = await cache.evictIfOverBudget("active");
+ expect(evicted).not.toContain("active");
+ expect(evicted).toContain("other");
+ });
+
+ it("returns empty when under budget", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store, { maxChunks: 100 });
+
+ await store.append("a", [chunk(1)]);
+ await store.append("b", [chunk(1)]);
+
+ const evicted = await cache.evictIfOverBudget(null);
+ expect(evicted).toEqual([]);
+ });
});
describe("cache.delete", () => {
- it("removes the conversation from the store", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
+ it("removes the conversation from the store", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
- await store.append("conv-1", [chunk(1), chunk(2)]);
- await cache.delete("conv-1");
+ await store.append("conv-1", [chunk(1), chunk(2)]);
+ await cache.delete("conv-1");
- const stored = await store.load("conv-1");
- expect(stored).toEqual([]);
- });
+ const stored = await store.load("conv-1");
+ expect(stored).toEqual([]);
+ });
- it("then load returns []", async () => {
- const store = createFakeStore();
- const cache = createConversationCache(store);
+ it("then load returns []", async () => {
+ const store = createFakeStore();
+ const cache = createConversationCache(store);
- await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]);
- await cache.delete("conv-1");
+ await cache.commit("conv-1", [chunk(1), chunk(2), chunk(3)]);
+ await cache.delete("conv-1");
- const result = await cache.load("conv-1");
- expect(result).toEqual([]);
- });
+ const result = await cache.load("conv-1");
+ expect(result).toEqual([]);
+ });
});
diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts
index 3d5743a..4953944 100644
--- a/src/features/conversation-cache/cache.ts
+++ b/src/features/conversation-cache/cache.ts
@@ -3,31 +3,31 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationChunkStore } from "./types";
export interface ConversationCache {
- /** Load all cached chunks for a conversation. */
- load(conversationId: string): Promise<readonly StoredChunk[]>;
+ /** Load all cached chunks for a conversation. */
+ load(conversationId: string): Promise<readonly StoredChunk[]>;
- /**
- * Load + reconcile + append new chunks.
- * Returns the merged cache (the new authoritative cache for this conversation).
- */
- commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
+ /**
+ * Load + reconcile + append new chunks.
+ * Returns the merged cache (the new authoritative cache for this conversation).
+ */
+ commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>;
- /** Return the `?sinceSeq=` cursor for the next incremental sync. */
- sinceSeq(conversationId: string): Promise<number>;
+ /** Return the `?sinceSeq=` cursor for the next incremental sync. */
+ sinceSeq(conversationId: string): Promise<number>;
- /**
- * Evict conversations over budget.
- * Returns the evicted conversationIds.
- */
- evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
+ /**
+ * Evict conversations over budget.
+ * Returns the evicted conversationIds.
+ */
+ evictIfOverBudget(activeConversationId: string | null): Promise<readonly string[]>;
- /** Delete all cached data for a single conversation (local forget). */
- delete(conversationId: string): Promise<void>;
+ /** Delete all cached data for a single conversation (local forget). */
+ delete(conversationId: string): Promise<void>;
}
export interface ConversationCacheOptions {
- /** Maximum total chunks across all conversations before eviction triggers. */
- readonly maxChunks?: number;
+ /** Maximum total chunks across all conversations before eviction triggers. */
+ readonly maxChunks?: number;
}
const DEFAULT_MAX_CHUNKS = 10_000;
@@ -38,41 +38,41 @@ const DEFAULT_MAX_CHUNKS = 10_000;
* The ONLY impurity is the injected `store`; all logic delegates to pure functions.
*/
export function createConversationCache(
- store: ConversationChunkStore,
- opts?: ConversationCacheOptions,
+ store: ConversationChunkStore,
+ opts?: ConversationCacheOptions,
): ConversationCache {
- const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
+ const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS;
- return {
- async load(conversationId) {
- return store.load(conversationId);
- },
+ return {
+ async load(conversationId) {
+ return store.load(conversationId);
+ },
- async commit(conversationId, incoming) {
- const cached = await store.load(conversationId);
- const { merged, toAppend } = reconcileCache(cached, incoming);
- if (toAppend.length > 0) {
- await store.append(conversationId, toAppend);
- }
- return merged;
- },
+ async commit(conversationId, incoming) {
+ const cached = await store.load(conversationId);
+ const { merged, toAppend } = reconcileCache(cached, incoming);
+ if (toAppend.length > 0) {
+ await store.append(conversationId, toAppend);
+ }
+ return merged;
+ },
- async sinceSeq(conversationId) {
- const cached = await store.load(conversationId);
- return nextSinceSeq(cached);
- },
+ async sinceSeq(conversationId) {
+ const cached = await store.load(conversationId);
+ return nextSinceSeq(cached);
+ },
- async evictIfOverBudget(activeConversationId) {
- const idx = await store.index();
- const toEvict = selectEvictions(idx, { maxChunks, activeConversationId });
- for (const id of toEvict) {
- await store.delete(id);
- }
- return toEvict;
- },
+ async evictIfOverBudget(activeConversationId) {
+ const idx = await store.index();
+ const toEvict = selectEvictions(idx, { maxChunks, activeConversationId });
+ for (const id of toEvict) {
+ await store.delete(id);
+ }
+ return toEvict;
+ },
- async delete(conversationId) {
- await store.delete(conversationId);
- },
- };
+ async delete(conversationId) {
+ await store.delete(conversationId);
+ },
+ };
}
diff --git a/src/features/conversation-cache/index.ts b/src/features/conversation-cache/index.ts
index 32e32d9..25e2af7 100644
--- a/src/features/conversation-cache/index.ts
+++ b/src/features/conversation-cache/index.ts
@@ -2,13 +2,13 @@ export type { ConversationCache, ConversationCacheOptions } from "./cache";
export { createConversationCache } from "./cache";
export { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
export type {
- ConversationCacheIndexEntry,
- ConversationChunkStore,
- ReconcileResult,
+ ConversationCacheIndexEntry,
+ ConversationChunkStore,
+ ReconcileResult,
} from "./types";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "conversation-cache",
- description: "IndexedDB-backed chunk cache with reconciliation",
+ name: "conversation-cache",
+ description: "IndexedDB-backed chunk cache with reconciliation",
} as const;
diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts
index 858460a..880c32e 100644
--- a/src/features/conversation-cache/logic.test.ts
+++ b/src/features/conversation-cache/logic.test.ts
@@ -4,137 +4,137 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic";
import type { ConversationCacheIndexEntry } from "./types";
const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => ({
- seq,
- role,
- chunk: { type: "text", text: `chunk-${seq}` },
+ seq,
+ role,
+ chunk: { type: "text", text: `chunk-${seq}` },
});
describe("reconcileCache", () => {
- it("merges and dedupes by seq", () => {
- const cached = [chunk(1), chunk(2)];
- const incoming = [chunk(2), chunk(3)];
- const result = reconcileCache(cached, incoming);
- expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]);
- });
-
- it("toAppend excludes already-cached seqs", () => {
- const cached = [chunk(1), chunk(2)];
- const incoming = [chunk(2), chunk(3)];
- const result = reconcileCache(cached, incoming);
- expect(result.toAppend).toEqual([chunk(3)]);
- });
-
- it("tolerates out-of-order incoming", () => {
- const cached = [chunk(1)];
- const incoming = [chunk(5), chunk(3), chunk(2)];
- const result = reconcileCache(cached, incoming);
- expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]);
- expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]);
- });
-
- it("returns empty merged and toAppend when both inputs are empty", () => {
- const result = reconcileCache([], []);
- expect(result.merged).toEqual([]);
- expect(result.toAppend).toEqual([]);
- });
-
- it("handles empty cached with incoming", () => {
- const incoming = [chunk(3), chunk(1)];
- const result = reconcileCache([], incoming);
- expect(result.merged).toEqual([chunk(1), chunk(3)]);
- expect(result.toAppend).toEqual([chunk(3), chunk(1)]);
- });
-
- it("handles cached with empty incoming", () => {
- const cached = [chunk(1), chunk(2)];
- const result = reconcileCache(cached, []);
- expect(result.merged).toEqual([chunk(1), chunk(2)]);
- expect(result.toAppend).toEqual([]);
- });
-
- it("is idempotent — re-reconciling same incoming produces same result", () => {
- const cached = [chunk(1)];
- const incoming = [chunk(2), chunk(3)];
- const first = reconcileCache(cached, incoming);
- const second = reconcileCache(first.merged, incoming);
- expect(second.merged).toEqual(first.merged);
- expect(second.toAppend).toEqual([]);
- });
+ it("merges and dedupes by seq", () => {
+ const cached = [chunk(1), chunk(2)];
+ const incoming = [chunk(2), chunk(3)];
+ const result = reconcileCache(cached, incoming);
+ expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3)]);
+ });
+
+ it("toAppend excludes already-cached seqs", () => {
+ const cached = [chunk(1), chunk(2)];
+ const incoming = [chunk(2), chunk(3)];
+ const result = reconcileCache(cached, incoming);
+ expect(result.toAppend).toEqual([chunk(3)]);
+ });
+
+ it("tolerates out-of-order incoming", () => {
+ const cached = [chunk(1)];
+ const incoming = [chunk(5), chunk(3), chunk(2)];
+ const result = reconcileCache(cached, incoming);
+ expect(result.merged).toEqual([chunk(1), chunk(2), chunk(3), chunk(5)]);
+ expect(result.toAppend).toEqual([chunk(5), chunk(3), chunk(2)]);
+ });
+
+ it("returns empty merged and toAppend when both inputs are empty", () => {
+ const result = reconcileCache([], []);
+ expect(result.merged).toEqual([]);
+ expect(result.toAppend).toEqual([]);
+ });
+
+ it("handles empty cached with incoming", () => {
+ const incoming = [chunk(3), chunk(1)];
+ const result = reconcileCache([], incoming);
+ expect(result.merged).toEqual([chunk(1), chunk(3)]);
+ expect(result.toAppend).toEqual([chunk(3), chunk(1)]);
+ });
+
+ it("handles cached with empty incoming", () => {
+ const cached = [chunk(1), chunk(2)];
+ const result = reconcileCache(cached, []);
+ expect(result.merged).toEqual([chunk(1), chunk(2)]);
+ expect(result.toAppend).toEqual([]);
+ });
+
+ it("is idempotent — re-reconciling same incoming produces same result", () => {
+ const cached = [chunk(1)];
+ const incoming = [chunk(2), chunk(3)];
+ const first = reconcileCache(cached, incoming);
+ const second = reconcileCache(first.merged, incoming);
+ expect(second.merged).toEqual(first.merged);
+ expect(second.toAppend).toEqual([]);
+ });
});
describe("nextSinceSeq", () => {
- it("returns max seq", () => {
- const cached = [chunk(1), chunk(5), chunk(3)];
- expect(nextSinceSeq(cached)).toBe(5);
- });
-
- it("returns 0 when empty", () => {
- expect(nextSinceSeq([])).toBe(0);
- });
-
- it("returns single seq for single chunk", () => {
- expect(nextSinceSeq([chunk(42)])).toBe(42);
- });
+ it("returns max seq", () => {
+ const cached = [chunk(1), chunk(5), chunk(3)];
+ expect(nextSinceSeq(cached)).toBe(5);
+ });
+
+ it("returns 0 when empty", () => {
+ expect(nextSinceSeq([])).toBe(0);
+ });
+
+ it("returns single seq for single chunk", () => {
+ expect(nextSinceSeq([chunk(42)])).toBe(42);
+ });
});
describe("selectEvictions", () => {
- it("never evicts the active conversation", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 },
- { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 },
- ];
- const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" });
- expect(result).not.toContain("active");
- expect(result).toContain("other");
- });
-
- it("evicts LRU until under budget", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
- { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 },
- { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 },
- { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 },
- ];
- // Total = 120, max = 60, need to evict 60+ chunks
- // LRU order: d(10), b(50), a(100), c(200)
- const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
- expect(result).toEqual(["d", "b"]);
- });
-
- it("is a no-op under budget", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 },
- { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 },
- ];
- const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null });
- expect(result).toEqual([]);
- });
-
- it("returns empty for empty index", () => {
- const result = selectEvictions([], { maxChunks: 100, activeConversationId: null });
- expect(result).toEqual([]);
- });
-
- it("tie-breaks by smaller maxSeq when lastAccess is equal", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 },
- { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 },
- { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 },
- ];
- // Total = 90, max = 60, need to evict 30+ chunks
- // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200)
- const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
- expect(result).toEqual(["b"]);
- });
-
- it("handles missing lastAccess (treated as 0)", () => {
- const index: ConversationCacheIndexEntry[] = [
- { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
- { conversationId: "b", chunkCount: 30, maxSeq: 30 },
- ];
- // Total = 60, max = 30, need to evict 30+ chunks
- // b has no lastAccess (0), a has 100
- const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null });
- expect(result).toEqual(["b"]);
- });
+ it("never evicts the active conversation", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "active", chunkCount: 100, maxSeq: 100, lastAccess: 1000 },
+ { conversationId: "other", chunkCount: 50, maxSeq: 50, lastAccess: 1 },
+ ];
+ const result = selectEvictions(index, { maxChunks: 50, activeConversationId: "active" });
+ expect(result).not.toContain("active");
+ expect(result).toContain("other");
+ });
+
+ it("evicts LRU until under budget", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
+ { conversationId: "b", chunkCount: 30, maxSeq: 30, lastAccess: 50 },
+ { conversationId: "c", chunkCount: 30, maxSeq: 30, lastAccess: 200 },
+ { conversationId: "d", chunkCount: 30, maxSeq: 30, lastAccess: 10 },
+ ];
+ // Total = 120, max = 60, need to evict 60+ chunks
+ // LRU order: d(10), b(50), a(100), c(200)
+ const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
+ expect(result).toEqual(["d", "b"]);
+ });
+
+ it("is a no-op under budget", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 10, maxSeq: 10, lastAccess: 100 },
+ { conversationId: "b", chunkCount: 10, maxSeq: 10, lastAccess: 50 },
+ ];
+ const result = selectEvictions(index, { maxChunks: 100, activeConversationId: null });
+ expect(result).toEqual([]);
+ });
+
+ it("returns empty for empty index", () => {
+ const result = selectEvictions([], { maxChunks: 100, activeConversationId: null });
+ expect(result).toEqual([]);
+ });
+
+ it("tie-breaks by smaller maxSeq when lastAccess is equal", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 30, maxSeq: 100, lastAccess: 50 },
+ { conversationId: "b", chunkCount: 30, maxSeq: 50, lastAccess: 50 },
+ { conversationId: "c", chunkCount: 30, maxSeq: 200, lastAccess: 50 },
+ ];
+ // Total = 90, max = 60, need to evict 30+ chunks
+ // All have same lastAccess, tie-break by maxSeq: b(50), a(100), c(200)
+ const result = selectEvictions(index, { maxChunks: 60, activeConversationId: null });
+ expect(result).toEqual(["b"]);
+ });
+
+ it("handles missing lastAccess (treated as 0)", () => {
+ const index: ConversationCacheIndexEntry[] = [
+ { conversationId: "a", chunkCount: 30, maxSeq: 30, lastAccess: 100 },
+ { conversationId: "b", chunkCount: 30, maxSeq: 30 },
+ ];
+ // Total = 60, max = 30, need to evict 30+ chunks
+ // b has no lastAccess (0), a has 100
+ const result = selectEvictions(index, { maxChunks: 30, activeConversationId: null });
+ expect(result).toEqual(["b"]);
+ });
});
diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts
index 4a4479e..3cb23e2 100644
--- a/src/features/conversation-cache/logic.ts
+++ b/src/features/conversation-cache/logic.ts
@@ -9,24 +9,24 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types";
* (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`.
*/
export function reconcileCache(
- cached: readonly StoredChunk[],
- incoming: readonly StoredChunk[],
+ cached: readonly StoredChunk[],
+ incoming: readonly StoredChunk[],
): ReconcileResult {
- const seen = new Set<number>();
- for (const chunk of cached) {
- seen.add(chunk.seq);
- }
+ const seen = new Set<number>();
+ for (const chunk of cached) {
+ seen.add(chunk.seq);
+ }
- const toAppend: StoredChunk[] = [];
- for (const chunk of incoming) {
- if (!seen.has(chunk.seq)) {
- toAppend.push(chunk);
- seen.add(chunk.seq);
- }
- }
+ const toAppend: StoredChunk[] = [];
+ for (const chunk of incoming) {
+ if (!seen.has(chunk.seq)) {
+ toAppend.push(chunk);
+ seen.add(chunk.seq);
+ }
+ }
- const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq);
- return { merged, toAppend };
+ const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq);
+ return { merged, toAppend };
}
/**
@@ -34,12 +34,12 @@ export function reconcileCache(
* This is the `?sinceSeq=` cursor for the next incremental sync.
*/
export function nextSinceSeq(cached: readonly StoredChunk[]): number {
- if (cached.length === 0) return 0;
- let max = 0;
- for (const chunk of cached) {
- if (chunk.seq > max) max = chunk.seq;
- }
- return max;
+ if (cached.length === 0) return 0;
+ let max = 0;
+ for (const chunk of cached) {
+ if (chunk.seq > max) max = chunk.seq;
+ }
+ return max;
}
/**
@@ -50,28 +50,28 @@ export function nextSinceSeq(cached: readonly StoredChunk[]): number {
* Returns [] when under budget.
*/
export function selectEvictions(
- index: readonly ConversationCacheIndexEntry[],
- opts: { maxChunks: number; activeConversationId: string | null },
+ index: readonly ConversationCacheIndexEntry[],
+ opts: { maxChunks: number; activeConversationId: string | null },
): readonly string[] {
- const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0);
- if (totalChunks <= opts.maxChunks) return [];
+ const totalChunks = index.reduce((sum, entry) => sum + entry.chunkCount, 0);
+ if (totalChunks <= opts.maxChunks) return [];
- const candidates = index
- .filter((entry) => entry.conversationId !== opts.activeConversationId)
- .sort((a, b) => {
- const aAccess = a.lastAccess ?? 0;
- const bAccess = b.lastAccess ?? 0;
- if (aAccess !== bAccess) return aAccess - bAccess;
- return a.maxSeq - b.maxSeq;
- });
+ const candidates = index
+ .filter((entry) => entry.conversationId !== opts.activeConversationId)
+ .sort((a, b) => {
+ const aAccess = a.lastAccess ?? 0;
+ const bAccess = b.lastAccess ?? 0;
+ if (aAccess !== bAccess) return aAccess - bAccess;
+ return a.maxSeq - b.maxSeq;
+ });
- let remaining = totalChunks;
- const evictions: string[] = [];
- for (const entry of candidates) {
- if (remaining <= opts.maxChunks) break;
- evictions.push(entry.conversationId);
- remaining -= entry.chunkCount;
- }
+ let remaining = totalChunks;
+ const evictions: string[] = [];
+ for (const entry of candidates) {
+ if (remaining <= opts.maxChunks) break;
+ evictions.push(entry.conversationId);
+ remaining -= entry.chunkCount;
+ }
- return evictions;
+ return evictions;
}
diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts
index 2a349cc..345ab71 100644
--- a/src/features/conversation-cache/types.ts
+++ b/src/features/conversation-cache/types.ts
@@ -2,10 +2,10 @@ import type { StoredChunk } from "@dispatch/wire";
/** Metadata entry for a cached conversation, used by eviction logic. */
export interface ConversationCacheIndexEntry {
- readonly conversationId: string;
- readonly chunkCount: number;
- readonly maxSeq: number;
- readonly lastAccess?: number;
+ readonly conversationId: string;
+ readonly chunkCount: number;
+ readonly maxSeq: number;
+ readonly lastAccess?: number;
}
/**
@@ -17,26 +17,26 @@ export interface ConversationCacheIndexEntry {
* All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
*/
export interface ConversationChunkStore {
- /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
- load(conversationId: string): Promise<readonly StoredChunk[]>;
+ /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */
+ load(conversationId: string): Promise<readonly StoredChunk[]>;
- /**
- * Append committed chunks to a conversation's cache.
- * MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
- */
- append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>;
+ /**
+ * Append committed chunks to a conversation's cache.
+ * MUST be idempotent on `seq`: re-appending an existing seq is a no-op.
+ */
+ append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void>;
- /** Delete all cached data for a conversation. */
- delete(conversationId: string): Promise<void>;
+ /** Delete all cached data for a conversation. */
+ delete(conversationId: string): Promise<void>;
- /** Return metadata for all cached conversations (for eviction). */
- index(): Promise<readonly ConversationCacheIndexEntry[]>;
+ /** Return metadata for all cached conversations (for eviction). */
+ index(): Promise<readonly ConversationCacheIndexEntry[]>;
}
/** Result of reconciling cached chunks with incoming authoritative chunks. */
export interface ReconcileResult {
- /** The merged, deduplicated, seq-ordered chunk list. */
- readonly merged: readonly StoredChunk[];
- /** The subset of incoming chunks that need to be appended (not already cached). */
- readonly toAppend: readonly StoredChunk[];
+ /** The merged, deduplicated, seq-ordered chunk list. */
+ readonly merged: readonly StoredChunk[];
+ /** The subset of incoming chunks that need to be appended (not already cached). */
+ readonly toAppend: readonly StoredChunk[];
}
diff --git a/src/features/cwd-lsp/index.ts b/src/features/cwd-lsp/index.ts
index 0c61425..36ea1b2 100644
--- a/src/features/cwd-lsp/index.ts
+++ b/src/features/cwd-lsp/index.ts
@@ -1,14 +1,14 @@
export type {
- CwdSaveResult,
- LoadLspStatus,
- LspStatusResult,
- SaveCwd,
+ CwdSaveResult,
+ LoadLspStatus,
+ LspStatusResult,
+ SaveCwd,
} from "./logic/view-model";
export { default as CwdField } from "./ui/CwdField.svelte";
export { default as LspStatusView } from "./ui/LspStatusView.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "cwd-lsp",
- description: "Per-conversation working directory + language-server status",
+ name: "cwd-lsp",
+ description: "Per-conversation working directory + language-server status",
} as const;
diff --git a/src/features/cwd-lsp/logic/view-model.test.ts b/src/features/cwd-lsp/logic/view-model.test.ts
index a06edeb..7bc0aad 100644
--- a/src/features/cwd-lsp/logic/view-model.test.ts
+++ b/src/features/cwd-lsp/logic/view-model.test.ts
@@ -1,101 +1,101 @@
import type { LspServerInfo } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import {
- cwdChanged,
- isSubmittableCwd,
- normalizeCwd,
- summarizeServers,
- viewLspServer,
- viewLspServers,
+ cwdChanged,
+ isSubmittableCwd,
+ normalizeCwd,
+ summarizeServers,
+ viewLspServer,
+ viewLspServers,
} from "./view-model";
const server = (over: Partial<LspServerInfo> = {}): LspServerInfo => ({
- id: "typescript",
- name: "TypeScript",
- root: "/home/me/project",
- extensions: [".ts", ".tsx"],
- state: "connected",
- ...over,
+ id: "typescript",
+ name: "TypeScript",
+ root: "/home/me/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected",
+ ...over,
});
describe("cwd helpers", () => {
- it("normalizeCwd trims surrounding whitespace", () => {
- expect(normalizeCwd(" /a/b ")).toBe("/a/b");
- expect(normalizeCwd("\t/x\n")).toBe("/x");
- });
+ it("normalizeCwd trims surrounding whitespace", () => {
+ expect(normalizeCwd(" /a/b ")).toBe("/a/b");
+ expect(normalizeCwd("\t/x\n")).toBe("/x");
+ });
- it("isSubmittableCwd is false for empty / whitespace-only", () => {
- expect(isSubmittableCwd("")).toBe(false);
- expect(isSubmittableCwd(" ")).toBe(false);
- expect(isSubmittableCwd("/a")).toBe(true);
- });
+ it("isSubmittableCwd is false for empty / whitespace-only", () => {
+ expect(isSubmittableCwd("")).toBe(false);
+ expect(isSubmittableCwd(" ")).toBe(false);
+ expect(isSubmittableCwd("/a")).toBe(true);
+ });
- it("cwdChanged: true only when a non-empty trimmed value differs from current", () => {
- expect(cwdChanged("/a/b", null)).toBe(true);
- expect(cwdChanged("/a/b", "/a/b")).toBe(false);
- expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change
- expect(cwdChanged("/a/c", "/a/b")).toBe(true);
- expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear)
- expect(cwdChanged(" ", null)).toBe(false);
- });
+ it("cwdChanged: true only when a non-empty trimmed value differs from current", () => {
+ expect(cwdChanged("/a/b", null)).toBe(true);
+ expect(cwdChanged("/a/b", "/a/b")).toBe(false);
+ expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change
+ expect(cwdChanged("/a/c", "/a/b")).toBe(true);
+ expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear)
+ expect(cwdChanged(" ", null)).toBe(false);
+ });
});
describe("viewLspServer", () => {
- it("connected → success badge, not busy, no error", () => {
- const v = viewLspServer(server({ state: "connected" }));
- expect(v.badge).toBe("success");
- expect(v.statusLabel).toBe("Connected");
- expect(v.busy).toBe(false);
- expect(v.error).toBeNull();
- expect(v.extensionsLabel).toBe(".ts .tsx");
- });
+ it("connected → success badge, not busy, no error", () => {
+ const v = viewLspServer(server({ state: "connected" }));
+ expect(v.badge).toBe("success");
+ expect(v.statusLabel).toBe("Connected");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ expect(v.extensionsLabel).toBe(".ts .tsx");
+ });
- it("starting / not-started → busy (spinner) with warning / neutral badge", () => {
- const starting = viewLspServer(server({ state: "starting" }));
- expect(starting.badge).toBe("warning");
- expect(starting.busy).toBe(true);
+ it("starting / not-started → busy (spinner) with warning / neutral badge", () => {
+ const starting = viewLspServer(server({ state: "starting" }));
+ expect(starting.badge).toBe("warning");
+ expect(starting.busy).toBe(true);
- const notStarted = viewLspServer(server({ state: "not-started" }));
- expect(notStarted.badge).toBe("neutral");
- expect(notStarted.busy).toBe(true);
- });
+ const notStarted = viewLspServer(server({ state: "not-started" }));
+ expect(notStarted.badge).toBe("neutral");
+ expect(notStarted.busy).toBe(true);
+ });
- it("error → error badge + surfaces the reason (with a fallback)", () => {
- const withReason = viewLspServer(server({ state: "error", error: "ENOENT" }));
- expect(withReason.badge).toBe("error");
- expect(withReason.busy).toBe(false);
- expect(withReason.error).toBe("ENOENT");
+ it("error → error badge + surfaces the reason (with a fallback)", () => {
+ const withReason = viewLspServer(server({ state: "error", error: "ENOENT" }));
+ expect(withReason.badge).toBe("error");
+ expect(withReason.busy).toBe(false);
+ expect(withReason.error).toBe("ENOENT");
- const noReason = viewLspServer(server({ state: "error" }));
- expect(noReason.error).toBe("Failed to start");
- });
+ const noReason = viewLspServer(server({ state: "error" }));
+ expect(noReason.error).toBe("Failed to start");
+ });
- it("viewLspServers maps a list preserving order", () => {
- const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]);
- expect(views.map((v) => v.id)).toEqual(["a", "b"]);
- });
+ it("viewLspServers maps a list preserving order", () => {
+ const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
});
describe("summarizeServers", () => {
- it("empty list", () => {
- expect(summarizeServers([])).toBe("No language servers");
- });
+ it("empty list", () => {
+ expect(summarizeServers([])).toBe("No language servers");
+ });
- it("counts connected / starting / errors", () => {
- expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected");
- expect(
- summarizeServers([
- server({ id: "a", state: "connected" }),
- server({ id: "b", state: "error" }),
- ]),
- ).toBe("1 connected, 1 error");
- expect(
- summarizeServers([
- server({ id: "a", state: "connected" }),
- server({ id: "b", state: "starting" }),
- server({ id: "c", state: "error" }),
- server({ id: "d", state: "error" }),
- ]),
- ).toBe("1 connected, 1 starting, 2 errors");
- });
+ it("counts connected / starting / errors", () => {
+ expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected");
+ expect(
+ summarizeServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 error");
+ expect(
+ summarizeServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "starting" }),
+ server({ id: "c", state: "error" }),
+ server({ id: "d", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 starting, 2 errors");
+ });
});
diff --git a/src/features/cwd-lsp/logic/view-model.ts b/src/features/cwd-lsp/logic/view-model.ts
index 97a3aca..7642418 100644
--- a/src/features/cwd-lsp/logic/view-model.ts
+++ b/src/features/cwd-lsp/logic/view-model.ts
@@ -16,15 +16,15 @@ import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract
/** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */
export type CwdSaveResult =
- | { readonly ok: true; readonly cwd: string | null }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly cwd: string | null }
+ | { readonly ok: false; readonly error: string };
export type SaveCwd = (cwd: string) => Promise<CwdSaveResult | null>;
/** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */
export type LspStatusResult =
- | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] }
+ | { readonly ok: false; readonly error: string };
export type LoadLspStatus = () => Promise<LspStatusResult | null>;
@@ -32,12 +32,12 @@ export type LoadLspStatus = () => Promise<LspStatusResult | null>;
/** Trim surrounding whitespace; the backend rejects an empty cwd. */
export function normalizeCwd(raw: string): string {
- return raw.trim();
+ return raw.trim();
}
/** Whether a typed cwd is submittable (non-empty after trim). */
export function isSubmittableCwd(raw: string): boolean {
- return normalizeCwd(raw).length > 0;
+ return normalizeCwd(raw).length > 0;
}
/**
@@ -45,9 +45,9 @@ export function isSubmittableCwd(raw: string): boolean {
* (unchanged, or empty) should be disabled.
*/
export function cwdChanged(typed: string, current: string | null): boolean {
- const next = normalizeCwd(typed);
- if (next.length === 0) return false;
- return next !== (current ?? "");
+ const next = normalizeCwd(typed);
+ if (next.length === 0) return false;
+ return next !== (current ?? "");
}
// ── LSP server status → display view ──────────────────────────────────────────
@@ -55,76 +55,76 @@ export function cwdChanged(typed: string, current: string | null): boolean {
export type Badge = "success" | "warning" | "error" | "neutral";
export interface LspServerView {
- readonly id: string;
- readonly name: string;
- readonly root: string;
- /** Space-joined extension list, e.g. ".ts .tsx". */
- readonly extensionsLabel: string;
- readonly state: LspServerState;
- readonly statusLabel: string;
- readonly badge: Badge;
- /** True while the state is transient (show a spinner). */
- readonly busy: boolean;
- /** The error reason when `state === "error"`, else null. */
- readonly error: string | null;
+ readonly id: string;
+ readonly name: string;
+ readonly root: string;
+ /** Space-joined extension list, e.g. ".ts .tsx". */
+ readonly extensionsLabel: string;
+ readonly state: LspServerState;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the state is transient (show a spinner). */
+ readonly busy: boolean;
+ /** The error reason when `state === "error"`, else null. */
+ readonly error: string | null;
}
/** Map a server's state to a display label + badge severity + busy flag. */
export function viewLspServer(server: LspServerInfo): LspServerView {
- let statusLabel: string;
- let badge: Badge;
- let busy = false;
- switch (server.state) {
- case "connected":
- statusLabel = "Connected";
- badge = "success";
- break;
- case "starting":
- statusLabel = "Starting…";
- badge = "warning";
- busy = true;
- break;
- case "not-started":
- statusLabel = "Not started";
- badge = "neutral";
- busy = true;
- break;
- case "error":
- statusLabel = "Error";
- badge = "error";
- break;
- }
- return {
- id: server.id,
- name: server.name,
- root: server.root,
- extensionsLabel: server.extensions.join(" "),
- state: server.state,
- statusLabel,
- badge,
- busy,
- error: server.state === "error" ? (server.error ?? "Failed to start") : null,
- };
+ let statusLabel: string;
+ let badge: Badge;
+ let busy = false;
+ switch (server.state) {
+ case "connected":
+ statusLabel = "Connected";
+ badge = "success";
+ break;
+ case "starting":
+ statusLabel = "Starting…";
+ badge = "warning";
+ busy = true;
+ break;
+ case "not-started":
+ statusLabel = "Not started";
+ badge = "neutral";
+ busy = true;
+ break;
+ case "error":
+ statusLabel = "Error";
+ badge = "error";
+ break;
+ }
+ return {
+ id: server.id,
+ name: server.name,
+ root: server.root,
+ extensionsLabel: server.extensions.join(" "),
+ state: server.state,
+ statusLabel,
+ badge,
+ busy,
+ error: server.state === "error" ? (server.error ?? "Failed to start") : null,
+ };
}
export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] {
- return servers.map(viewLspServer);
+ return servers.map(viewLspServer);
}
/** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */
export function summarizeServers(servers: readonly LspServerInfo[]): string {
- if (servers.length === 0) return "No language servers";
- let connected = 0;
- let errored = 0;
- let pending = 0;
- for (const s of servers) {
- if (s.state === "connected") connected++;
- else if (s.state === "error") errored++;
- else pending++;
- }
- const parts: string[] = [];
- if (connected > 0) parts.push(`${connected} connected`);
- if (pending > 0) parts.push(`${pending} starting`);
- if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`);
- return parts.join(", ");
+ if (servers.length === 0) return "No language servers";
+ let connected = 0;
+ let errored = 0;
+ let pending = 0;
+ for (const s of servers) {
+ if (s.state === "connected") connected++;
+ else if (s.state === "error") errored++;
+ else pending++;
+ }
+ const parts: string[] = [];
+ if (connected > 0) parts.push(`${connected} connected`);
+ if (pending > 0) parts.push(`${pending} starting`);
+ if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`);
+ return parts.join(", ");
}
diff --git a/src/features/cwd-lsp/ui/CwdField.svelte b/src/features/cwd-lsp/ui/CwdField.svelte
index bd8b870..c83f5ce 100644
--- a/src/features/cwd-lsp/ui/CwdField.svelte
+++ b/src/features/cwd-lsp/ui/CwdField.svelte
@@ -1,96 +1,96 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model";
- let {
- cwd,
- canEdit,
- save,
- }: {
- /** The active conversation's persisted cwd, or null when unset. */
- cwd: string | null;
- /** Whether a real conversation is focused (a draft can't persist a cwd yet). */
- canEdit: boolean;
- save: SaveCwd;
- } = $props();
+ let {
+ cwd,
+ canEdit,
+ save,
+ }: {
+ /** The active conversation's persisted cwd, or null when unset. */
+ cwd: string | null;
+ /** Whether a real conversation is focused (a draft can't persist a cwd yet). */
+ canEdit: boolean;
+ save: SaveCwd;
+ } = $props();
- // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading
- // the prop directly into initial $state would only capture its first value.)
- let value = $state("");
- let lastSeed = $state("");
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
+ // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading
+ // the prop directly into initial $state would only capture its first value.)
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
- // Seed the input from the persisted cwd (it loads async). Only reseed while the
- // field is untouched, so an in-flight load can't clobber what the user typed.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- $effect(() => {
- const incoming = cwd ?? "";
- untrack(() => {
- if (value === lastSeed) value = incoming;
- lastSeed = incoming;
- });
- });
+ // Seed the input from the persisted cwd (it loads async). Only reseed while the
+ // field is untouched, so an in-flight load can't clobber what the user typed.
+ // Re-mounted per conversation, so there is no cross-tab bleed.
+ $effect(() => {
+ const incoming = cwd ?? "";
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
- const dirty = $derived(cwdChanged(value, cwd));
+ const dirty = $derived(cwdChanged(value, cwd));
- async function handleSave() {
- if (saving || !canEdit || !dirty) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(normalizeCwd(value));
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- }
- }
+ async function handleSave() {
+ if (saving || !canEdit || !dirty) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(normalizeCwd(value));
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ }
+ }
- function onInput() {
- justSaved = false;
- error = null;
- }
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Working directory</span>
- <div class="flex items-center gap-2">
- <input
- type="text"
- class="input input-bordered input-sm w-full font-mono text-xs"
- placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"}
- bind:value
- disabled={!canEdit || saving}
- oninput={onInput}
- onkeydown={(e) => {
- if (e.key === "Enter") handleSave();
- }}
- aria-label="Working directory"
- />
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={!canEdit || saving || !dirty}
- onclick={handleSave}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
- {#if !canEdit}
- <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p>
- {:else if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved && !dirty}
- <p class="text-xs text-success">Saved.</p>
- {:else}
- <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Working directory</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"}
+ bind:value
+ disabled={!canEdit || saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Working directory"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={!canEdit || saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if !canEdit}
+ <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved.</p>
+ {:else}
+ <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p>
+ {/if}
</div>
diff --git a/src/features/cwd-lsp/ui/LspStatusView.svelte b/src/features/cwd-lsp/ui/LspStatusView.svelte
index 77603a1..c8c9ea8 100644
--- a/src/features/cwd-lsp/ui/LspStatusView.svelte
+++ b/src/features/cwd-lsp/ui/LspStatusView.svelte
@@ -1,127 +1,129 @@
<script lang="ts">
- import { untrack } from "svelte";
- import {
- type Badge,
- type LoadLspStatus,
- type LspServerView,
- summarizeServers,
- viewLspServers,
- } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import {
+ type Badge,
+ type LoadLspStatus,
+ type LspServerView,
+ summarizeServers,
+ viewLspServers,
+ } from "../logic/view-model";
- let {
- cwd,
- canView,
- load,
- }: {
- /** The active conversation's cwd — the trigger to (re)load when it changes. */
- cwd: string | null;
- /** Whether a real conversation is focused. */
- canView: boolean;
- load: LoadLspStatus;
- } = $props();
+ let {
+ cwd,
+ canView,
+ load,
+ }: {
+ /** The active conversation's cwd — the trigger to (re)load when it changes. */
+ cwd: string | null;
+ /** Whether a real conversation is focused. */
+ canView: boolean;
+ load: LoadLspStatus;
+ } = $props();
- const badgeClass: Record<Badge, string> = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- neutral: "badge-ghost",
- };
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
- let servers = $state<readonly LspServerView[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
- let loadedCwd = $state<string | null>(null);
- let hasLoaded = $state(false);
- let summary = $state("");
+ let servers = $state<readonly LspServerView[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let loadedCwd = $state<string | null>(null);
+ let hasLoaded = $state(false);
+ let summary = $state("");
- async function refresh() {
- if (!canView) return;
- loading = true;
- error = null;
- const result = await load();
- loading = false;
- if (result === null) return;
- hasLoaded = true;
- if (result.ok) {
- servers = viewLspServers(result.servers);
- summary = summarizeServers(result.servers);
- loadedCwd = result.cwd;
- } else {
- error = result.error;
- }
- }
+ async function refresh() {
+ if (!canView) return;
+ loading = true;
+ error = null;
+ const result = await load();
+ loading = false;
+ if (result === null) return;
+ hasLoaded = true;
+ if (result.ok) {
+ servers = viewLspServers(result.servers);
+ summary = summarizeServers(result.servers);
+ loadedCwd = result.cwd;
+ } else {
+ error = result.error;
+ }
+ }
- // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET
- // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to
- // the value we already loaded for.
- $effect(() => {
- const target = cwd;
- const can = canView;
- untrack(() => {
- if (!can) return;
- if (!hasLoaded || target !== loadedCwd) void refresh();
- });
- });
+ // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET
+ // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to
+ // the value we already loaded for.
+ $effect(() => {
+ const target = cwd;
+ const can = canView;
+ untrack(() => {
+ if (!can) return;
+ if (!hasLoaded || target !== loadedCwd) void refresh();
+ });
+ });
</script>
<div class="flex flex-col gap-2">
- <div class="flex items-center justify-between gap-2">
- <span class="text-xs opacity-70">
- {#if loading}
- Resolving…
- {:else if hasLoaded && loadedCwd !== null}
- {summary}
- {:else}
- Language servers
- {/if}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={!canView || loading}
- onclick={() => refresh()}
- aria-label="Refresh language server status"
- >
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Refresh
- {/if}
- </button>
- </div>
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs opacity-70">
+ {#if loading}
+ Resolving…
+ {:else if hasLoaded && loadedCwd !== null}
+ {summary}
+ {:else}
+ Language servers
+ {/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!canView || loading}
+ onclick={() => refresh()}
+ aria-label="Refresh language server status"
+ >
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
- {#if !canView}
- <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p>
- {:else if error}
- <p class="text-xs text-error">{error}</p>
- {:else if hasLoaded && loadedCwd === null}
- <p class="text-xs opacity-60">
- Set a working directory in the Model panel to enable language servers.
- </p>
- {:else if hasLoaded && servers.length === 0 && !loading}
- <p class="text-xs opacity-60">No language servers configured for this directory.</p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each servers as server (server.id)}
- <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium">{server.name}</span>
- <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
- {#if server.busy}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {server.statusLabel}
- </span>
- </div>
- {#if server.extensionsLabel}
- <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span>
- {/if}
- <span class="truncate font-mono text-xs opacity-50" title={server.root}>{server.root}</span>
- {#if server.error}
- <span class="font-mono text-xs text-error">{server.error}</span>
- {/if}
- </li>
- {/each}
- </ul>
- {/if}
+ {#if !canView}
+ <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if hasLoaded && loadedCwd === null}
+ <p class="text-xs opacity-60">
+ Set a working directory in the Model panel to enable language servers.
+ </p>
+ {:else if hasLoaded && servers.length === 0 && !loading}
+ <p class="text-xs opacity-60">No language servers configured for this directory.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each servers as server (server.id)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium">{server.name}</span>
+ <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
+ {#if server.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {server.statusLabel}
+ </span>
+ </div>
+ {#if server.extensionsLabel}
+ <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span>
+ {/if}
+ <span class="truncate font-mono text-xs opacity-50" title={server.root}
+ >{server.root}</span
+ >
+ {#if server.error}
+ <span class="font-mono text-xs text-error">{server.error}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
</div>
diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts
index f5406b2..ff3aefa 100644
--- a/src/features/markdown/index.ts
+++ b/src/features/markdown/index.ts
@@ -3,6 +3,6 @@ export { default as Markdown } from "./ui/Markdown.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "markdown",
- description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)",
+ name: "markdown",
+ description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)",
} as const;
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 7dbb878..54b0086 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest";
import { renderMarkdown } from "./markdown";
describe("renderMarkdown", () => {
- it("renders GFM markdown (headings, emphasis)", () => {
- const html = renderMarkdown("# Title\n\nSome **bold** text.");
- expect(html).toContain("<h1");
- expect(html).toContain("Title");
- expect(html).toContain("<strong>bold</strong>");
- });
-
- it("highlights fenced code for a known language", () => {
- const html = renderMarkdown("```javascript\nconst x = 1;\n```");
- expect(html).toContain("language-javascript");
- expect(html).toContain("hljs-keyword"); // `const` got highlighted
- });
-
- it("resolves language aliases (js -> javascript)", () => {
- const html = renderMarkdown("```js\nconst x = 1;\n```");
- expect(html).toContain("hljs-keyword");
- });
-
- it("escapes code for an unknown language without throwing", () => {
- const html = renderMarkdown("```nope\n<b>x</b>\n```");
- expect(html).toContain("&lt;b&gt;");
- });
-
- it("sanitizes dangerous HTML", () => {
- const html = renderMarkdown("Hi <script>alert(1)</script> there");
- expect(html).not.toContain("<script>");
- expect(html).toContain("Hi");
- });
-
- it("balances dangling bold emphasis while streaming", () => {
- expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>");
- });
-
- it("does not balance delimiters when not streaming", () => {
- expect(renderMarkdown("a **bold")).not.toContain("<strong>");
- });
-
- it("wraps fenced code blocks with a copy button", () => {
- const html = renderMarkdown("```js\nconst x = 1;\n```");
- expect(html).toContain("code-block");
- expect(html).toContain("data-copy");
- expect(html).toContain("<pre>");
- });
-
- it("does not add a copy button to inline code", () => {
- const html = renderMarkdown("use `npm run dev` please");
- expect(html).not.toContain("data-copy");
- expect(html).toContain("<code>npm run dev</code>");
- });
-
- it("returns an empty string for empty input", () => {
- expect(renderMarkdown("")).toBe("");
- });
+ it("renders GFM markdown (headings, emphasis)", () => {
+ const html = renderMarkdown("# Title\n\nSome **bold** text.");
+ expect(html).toContain("<h1");
+ expect(html).toContain("Title");
+ expect(html).toContain("<strong>bold</strong>");
+ });
+
+ it("highlights fenced code for a known language", () => {
+ const html = renderMarkdown("```javascript\nconst x = 1;\n```");
+ expect(html).toContain("language-javascript");
+ expect(html).toContain("hljs-keyword"); // `const` got highlighted
+ });
+
+ it("resolves language aliases (js -> javascript)", () => {
+ const html = renderMarkdown("```js\nconst x = 1;\n```");
+ expect(html).toContain("hljs-keyword");
+ });
+
+ it("escapes code for an unknown language without throwing", () => {
+ const html = renderMarkdown("```nope\n<b>x</b>\n```");
+ expect(html).toContain("&lt;b&gt;");
+ });
+
+ it("sanitizes dangerous HTML", () => {
+ const html = renderMarkdown("Hi <script>alert(1)</script> there");
+ expect(html).not.toContain("<script>");
+ expect(html).toContain("Hi");
+ });
+
+ it("balances dangling bold emphasis while streaming", () => {
+ expect(renderMarkdown("a **bold", { streaming: true })).toContain("<strong>bold</strong>");
+ });
+
+ it("does not balance delimiters when not streaming", () => {
+ expect(renderMarkdown("a **bold")).not.toContain("<strong>");
+ });
+
+ it("wraps fenced code blocks with a copy button", () => {
+ const html = renderMarkdown("```js\nconst x = 1;\n```");
+ expect(html).toContain("code-block");
+ expect(html).toContain("data-copy");
+ expect(html).toContain("<pre>");
+ });
+
+ it("does not add a copy button to inline code", () => {
+ const html = renderMarkdown("use `npm run dev` please");
+ expect(html).not.toContain("data-copy");
+ expect(html).toContain("<code>npm run dev</code>");
+ });
+
+ it("returns an empty string for empty input", () => {
+ expect(renderMarkdown("")).toBe("");
+ });
});
diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts
index 3a6e5a6..ad8a8bd 100644
--- a/src/features/markdown/logic/markdown.ts
+++ b/src/features/markdown/logic/markdown.ts
@@ -39,88 +39,88 @@ import { markedHighlight } from "marked-highlight";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
- bash,
- c,
- cpp,
- csharp,
- css,
- go,
- java,
- javascript,
- json,
- markdown: markdownLang,
- php,
- plaintext,
- python,
- ruby,
- rust,
- shell,
- sql,
- typescript,
- xml,
- yaml,
+ bash,
+ c,
+ cpp,
+ csharp,
+ css,
+ go,
+ java,
+ javascript,
+ json,
+ markdown: markdownLang,
+ php,
+ plaintext,
+ python,
+ ruby,
+ rust,
+ shell,
+ sql,
+ typescript,
+ xml,
+ yaml,
};
for (const [name, lang] of Object.entries(HOT_LANGUAGES)) {
- hljs.registerLanguage(name, lang);
+ hljs.registerLanguage(name, lang);
}
// Normalize common fence aliases to canonical highlight.js names.
const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
+ js: "javascript",
+ jsx: "javascript",
+ mjs: "javascript",
+ cjs: "javascript",
+ ts: "typescript",
+ tsx: "typescript",
+ py: "python",
+ py3: "python",
+ rb: "ruby",
+ sh: "bash",
+ zsh: "bash",
+ yml: "yaml",
+ "c++": "cpp",
+ cxx: "cpp",
+ "c#": "csharp",
+ cs: "csharp",
+ htm: "xml",
+ html: "xml",
+ svg: "xml",
+ md: "markdown",
+ mdx: "markdown",
+ golang: "go",
+ rs: "rust",
};
function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
+ const lower = lang.toLowerCase().trim();
+ return ALIASES[lower] ?? lower;
}
function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
+ return s
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
}
const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- highlight(code: string, lang: string): string {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- if (!hljs.getLanguage(name)) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- { gfm: true, breaks: true },
+ markedHighlight({
+ emptyLangClass: "hljs",
+ langPrefix: "hljs language-",
+ highlight(code: string, lang: string): string {
+ if (!lang) return escapeHtml(code);
+ const name = normalizeLang(lang);
+ if (!hljs.getLanguage(name)) return escapeHtml(code);
+ try {
+ return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
+ } catch {
+ return escapeHtml(code);
+ }
+ },
+ }),
+ { gfm: true, breaks: true },
);
/**
@@ -128,14 +128,14 @@ const md = new Marked(
* partial text renders cleanly instead of flashing raw markers.
*/
function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) ?? []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) ?? []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
+ let out = src;
+ const fenceCount = (out.match(/^```/gm) ?? []).length;
+ if (fenceCount % 2 !== 0) out += "\n```";
+ const boldCount = (out.match(/\*\*/g) ?? []).length;
+ if (boldCount % 2 !== 0) out += "**";
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
+ if (inlineCode % 2 !== 0) out += "`";
+ return out;
}
// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
@@ -144,22 +144,22 @@ function closeOpenDelimiters(src: string): string {
// `data-copy` is the delegation hook the component listens for; DOMPurify keeps
// `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched.
const COPY_BUTTON =
- '<button type="button" data-copy aria-label="Copy code"' +
- ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
+ '<button type="button" data-copy aria-label="Copy code"' +
+ ' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
function addCopyButtons(html: string): string {
- return html
- .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
- .replace(/<\/pre>/g, "</pre></div>");
+ return html
+ .replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
+ .replace(/<\/pre>/g, "</pre></div>");
}
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
- const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
- try {
- const raw = md.parse(src) as string;
- return DOMPurify.sanitize(addCopyButtons(raw));
- } catch {
- return "";
- }
+ const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
+ try {
+ const raw = md.parse(src) as string;
+ return DOMPurify.sanitize(addCopyButtons(raw));
+ } catch {
+ return "";
+ }
}
diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte
index b828ab9..72b892b 100644
--- a/src/features/markdown/ui/Markdown.svelte
+++ b/src/features/markdown/ui/Markdown.svelte
@@ -1,58 +1,58 @@
<script lang="ts">
- import { renderMarkdown } from "../logic/markdown";
-
- let {
- text,
- streaming = false,
- }: {
- text: string;
- /** Balance dangling delimiters while the message is still generating. */
- streaming?: boolean;
- } = $props();
-
- // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
- const html = $derived(renderMarkdown(text, { streaming }));
-
- let container: HTMLElement;
-
- // One delegated listener on the stable container handles every code block's
- // copy button — including blocks re-created when `html` changes (streaming),
- // since the listener lives on the container, not the buttons. Clipboard is the
- // edge effect; absent (insecure context) → no-op.
- $effect(() => {
- const el = container;
- if (el === undefined) return;
-
- const onClick = (event: Event): void => {
- const target = event.target;
- if (!(target instanceof Element)) return;
- const button = target.closest<HTMLButtonElement>("[data-copy]");
- if (button === null) return;
-
- const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
- const clipboard = navigator.clipboard;
- if (clipboard === undefined) return;
-
- void clipboard
- .writeText(code)
- .then(() => {
- const prev = button.textContent;
- button.textContent = "Copied";
- setTimeout(() => {
- button.textContent = prev;
- }, 1200);
- })
- .catch(() => {
- // Clipboard denied — leave the button as-is.
- });
- };
-
- el.addEventListener("click", onClick);
- return () => el.removeEventListener("click", onClick);
- });
+ import { renderMarkdown } from "../logic/markdown";
+
+ let {
+ text,
+ streaming = false,
+ }: {
+ text: string;
+ /** Balance dangling delimiters while the message is still generating. */
+ streaming?: boolean;
+ } = $props();
+
+ // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown.
+ const html = $derived(renderMarkdown(text, { streaming }));
+
+ let container: HTMLElement;
+
+ // One delegated listener on the stable container handles every code block's
+ // copy button — including blocks re-created when `html` changes (streaming),
+ // since the listener lives on the container, not the buttons. Clipboard is the
+ // edge effect; absent (insecure context) → no-op.
+ $effect(() => {
+ const el = container;
+ if (el === undefined) return;
+
+ const onClick = (event: Event): void => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ const button = target.closest<HTMLButtonElement>("[data-copy]");
+ if (button === null) return;
+
+ const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? "";
+ const clipboard = navigator.clipboard;
+ if (clipboard === undefined) return;
+
+ void clipboard
+ .writeText(code)
+ .then(() => {
+ const prev = button.textContent;
+ button.textContent = "Copied";
+ setTimeout(() => {
+ button.textContent = prev;
+ }, 1200);
+ })
+ .catch(() => {
+ // Clipboard denied — leave the button as-is.
+ });
+ };
+
+ el.addEventListener("click", onClick);
+ return () => el.removeEventListener("click", onClick);
+ });
</script>
<div class="markdown-body" bind:this={container}>
- <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
- {@html html}
+ <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. -->
+ {@html html}
</div>
diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts
index e34a4af..d65b3d1 100644
--- a/src/features/markdown/ui/markdown.test.ts
+++ b/src/features/markdown/ui/markdown.test.ts
@@ -3,38 +3,38 @@ import { describe, expect, it, vi } from "vitest";
import Markdown from "./Markdown.svelte";
describe("Markdown", () => {
- it("renders markdown into a .markdown-body container", () => {
- const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } });
+ it("renders markdown into a .markdown-body container", () => {
+ const { container } = render(Markdown, { props: { text: "# Hello\n\n**hi**" } });
- expect(container.querySelector(".markdown-body")).not.toBeNull();
- expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
- expect(container.querySelector("strong")?.textContent).toBe("hi");
- });
+ expect(container.querySelector(".markdown-body")).not.toBeNull();
+ expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument();
+ expect(container.querySelector("strong")?.textContent).toBe("hi");
+ });
- it("strips dangerous markup", () => {
- const { container } = render(Markdown, {
- props: { text: "before <script>alert(1)</script> after" },
- });
+ it("strips dangerous markup", () => {
+ const { container } = render(Markdown, {
+ props: { text: "before <script>alert(1)</script> after" },
+ });
- expect(container.querySelector("script")).toBeNull();
- expect(container.textContent).toContain("before");
- });
+ expect(container.querySelector("script")).toBeNull();
+ expect(container.textContent).toContain("before");
+ });
- it("renders a copy button on a code block that copies the code to the clipboard", async () => {
- const writeText = vi.fn().mockResolvedValue(undefined);
- Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+ it("renders a copy button on a code block that copies the code to the clipboard", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
- const { container } = render(Markdown, {
- props: { text: "```js\nconst x = 1;\n```" },
- });
+ const { container } = render(Markdown, {
+ props: { text: "```js\nconst x = 1;\n```" },
+ });
- const button = container.querySelector<HTMLElement>("[data-copy]");
- expect(button).not.toBeNull();
- if (button === null) throw new Error("expected a copy button");
+ const button = container.querySelector<HTMLElement>("[data-copy]");
+ expect(button).not.toBeNull();
+ if (button === null) throw new Error("expected a copy button");
- await fireEvent.click(button);
+ await fireEvent.click(button);
- expect(writeText).toHaveBeenCalledTimes(1);
- expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;");
- });
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;");
+ });
});
diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts
index a161992..9abc023 100644
--- a/src/features/mcp/index.ts
+++ b/src/features/mcp/index.ts
@@ -3,6 +3,6 @@ export { default as McpStatusView } from "./ui/McpStatusView.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "mcp",
- description: "Per-conversation MCP (Model Context Protocol) server status",
+ name: "mcp",
+ description: "Per-conversation MCP (Model Context Protocol) server status",
} as const;
diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts
index 23bf20f..d1a77d1 100644
--- a/src/features/mcp/logic/view-model.test.ts
+++ b/src/features/mcp/logic/view-model.test.ts
@@ -3,86 +3,86 @@ import { describe, expect, it } from "vitest";
import { summarizeMcpServers, viewMcpServer, viewMcpServers } from "./view-model";
const server = (over: Partial<McpServerInfo> = {}): McpServerInfo => ({
- id: "freecad",
- state: "connected",
- toolCount: 12,
- ...over,
+ id: "freecad",
+ state: "connected",
+ toolCount: 12,
+ ...over,
});
describe("viewMcpServer", () => {
- it("connected → success badge, not busy, no error, passes toolCount", () => {
- const v = viewMcpServer(server({ toolCount: 5 }));
- expect(v.badge).toBe("success");
- expect(v.statusLabel).toBe("Connected");
- expect(v.busy).toBe(false);
- expect(v.error).toBeNull();
- expect(v.toolCount).toBe(5);
- expect(v.configSource).toBeNull();
- });
+ it("connected → success badge, not busy, no error, passes toolCount", () => {
+ const v = viewMcpServer(server({ toolCount: 5 }));
+ expect(v.badge).toBe("success");
+ expect(v.statusLabel).toBe("Connected");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ expect(v.toolCount).toBe(5);
+ expect(v.configSource).toBeNull();
+ });
- it("connecting → warning badge + busy (spinner)", () => {
- const v = viewMcpServer(server({ state: "connecting" }));
- expect(v.badge).toBe("warning");
- expect(v.statusLabel).toBe("Connecting…");
- expect(v.busy).toBe(true);
- expect(v.error).toBeNull();
- });
+ it("connecting → warning badge + busy (spinner)", () => {
+ const v = viewMcpServer(server({ state: "connecting" }));
+ expect(v.badge).toBe("warning");
+ expect(v.statusLabel).toBe("Connecting…");
+ expect(v.busy).toBe(true);
+ expect(v.error).toBeNull();
+ });
- it("disconnected → neutral badge, not busy", () => {
- const v = viewMcpServer(server({ state: "disconnected" }));
- expect(v.badge).toBe("neutral");
- expect(v.statusLabel).toBe("Disconnected");
- expect(v.busy).toBe(false);
- expect(v.error).toBeNull();
- });
+ it("disconnected → neutral badge, not busy", () => {
+ const v = viewMcpServer(server({ state: "disconnected" }));
+ expect(v.badge).toBe("neutral");
+ expect(v.statusLabel).toBe("Disconnected");
+ expect(v.busy).toBe(false);
+ expect(v.error).toBeNull();
+ });
- it("error → error badge + surfaces the reason (with a fallback)", () => {
- const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" }));
- expect(withReason.badge).toBe("error");
- expect(withReason.busy).toBe(false);
- expect(withReason.error).toBe("ENOENT: npx");
+ it("error → error badge + surfaces the reason (with a fallback)", () => {
+ const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" }));
+ expect(withReason.badge).toBe("error");
+ expect(withReason.busy).toBe(false);
+ expect(withReason.error).toBe("ENOENT: npx");
- const noReason = viewMcpServer(server({ state: "error" }));
- expect(noReason.error).toBe("Failed to connect");
- });
+ const noReason = viewMcpServer(server({ state: "error" }));
+ expect(noReason.error).toBe("Failed to connect");
+ });
- it("passes through configSource when present", () => {
- const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" }));
- expect(v.configSource).toBe(".dispatch/mcp.json");
- });
+ it("passes through configSource when present", () => {
+ const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" }));
+ expect(v.configSource).toBe(".dispatch/mcp.json");
+ });
- it("viewMcpServers maps a list preserving order", () => {
- const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]);
- expect(views.map((v) => v.id)).toEqual(["a", "b"]);
- });
+ it("viewMcpServers maps a list preserving order", () => {
+ const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
});
describe("summarizeMcpServers", () => {
- it("empty list", () => {
- expect(summarizeMcpServers([])).toBe("No MCP servers");
- });
+ it("empty list", () => {
+ expect(summarizeMcpServers([])).toBe("No MCP servers");
+ });
- it("counts connected / connecting / disconnected / errors", () => {
- expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected");
- expect(
- summarizeMcpServers([
- server({ id: "a", state: "connected" }),
- server({ id: "b", state: "error" }),
- ]),
- ).toBe("1 connected, 1 error");
- expect(
- summarizeMcpServers([
- server({ id: "a", state: "connected" }),
- server({ id: "b", state: "connecting" }),
- server({ id: "c", state: "disconnected" }),
- server({ id: "d", state: "error" }),
- server({ id: "e", state: "error" }),
- ]),
- ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors");
- });
+ it("counts connected / connecting / disconnected / errors", () => {
+ expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected");
+ expect(
+ summarizeMcpServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 error");
+ expect(
+ summarizeMcpServers([
+ server({ id: "a", state: "connected" }),
+ server({ id: "b", state: "connecting" }),
+ server({ id: "c", state: "disconnected" }),
+ server({ id: "d", state: "error" }),
+ server({ id: "e", state: "error" }),
+ ]),
+ ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors");
+ });
- it("lists only non-zero buckets", () => {
- expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected");
- expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting");
- });
+ it("lists only non-zero buckets", () => {
+ expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected");
+ expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting");
+ });
});
diff --git a/src/features/mcp/logic/view-model.ts b/src/features/mcp/logic/view-model.ts
index 247f804..fdff78a 100644
--- a/src/features/mcp/logic/view-model.ts
+++ b/src/features/mcp/logic/view-model.ts
@@ -16,8 +16,8 @@ import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract
/** Outcome of `GET /conversations/:id/mcp`; `null` when no real conversation is focused. */
export type McpStatusResult =
- | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] }
+ | { readonly ok: false; readonly error: string };
export type LoadMcpStatus = () => Promise<McpStatusResult | null>;
@@ -26,18 +26,18 @@ export type LoadMcpStatus = () => Promise<McpStatusResult | null>;
export type Badge = "success" | "warning" | "error" | "neutral";
export interface McpServerView {
- readonly id: string;
- readonly state: McpServerState;
- readonly statusLabel: string;
- readonly badge: Badge;
- /** True while the state is transient (show a spinner). */
- readonly busy: boolean;
- /** The error reason when `state === "error"`, else null. */
- readonly error: string | null;
- /** Number of tools discovered from this server. */
- readonly toolCount: number;
- /** Which config source the server was resolved from, else null. */
- readonly configSource: string | null;
+ readonly id: string;
+ readonly state: McpServerState;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the state is transient (show a spinner). */
+ readonly busy: boolean;
+ /** The error reason when `state === "error"`, else null. */
+ readonly error: string | null;
+ /** Number of tools discovered from this server. */
+ readonly toolCount: number;
+ /** Which config source the server was resolved from, else null. */
+ readonly configSource: string | null;
}
/**
@@ -47,42 +47,42 @@ export interface McpServerView {
* → error, and `disconnected` (a stable idle state) → neutral.
*/
export function viewMcpServer(server: McpServerInfo): McpServerView {
- let statusLabel: string;
- let badge: Badge;
- let busy = false;
- switch (server.state) {
- case "connected":
- statusLabel = "Connected";
- badge = "success";
- break;
- case "connecting":
- statusLabel = "Connecting…";
- badge = "warning";
- busy = true;
- break;
- case "disconnected":
- statusLabel = "Disconnected";
- badge = "neutral";
- break;
- case "error":
- statusLabel = "Error";
- badge = "error";
- break;
- }
- return {
- id: server.id,
- state: server.state,
- statusLabel,
- badge,
- busy,
- error: server.state === "error" ? (server.error ?? "Failed to connect") : null,
- toolCount: server.toolCount,
- configSource: server.configSource ?? null,
- };
+ let statusLabel: string;
+ let badge: Badge;
+ let busy = false;
+ switch (server.state) {
+ case "connected":
+ statusLabel = "Connected";
+ badge = "success";
+ break;
+ case "connecting":
+ statusLabel = "Connecting…";
+ badge = "warning";
+ busy = true;
+ break;
+ case "disconnected":
+ statusLabel = "Disconnected";
+ badge = "neutral";
+ break;
+ case "error":
+ statusLabel = "Error";
+ badge = "error";
+ break;
+ }
+ return {
+ id: server.id,
+ state: server.state,
+ statusLabel,
+ badge,
+ busy,
+ error: server.state === "error" ? (server.error ?? "Failed to connect") : null,
+ toolCount: server.toolCount,
+ configSource: server.configSource ?? null,
+ };
}
export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] {
- return servers.map(viewMcpServer);
+ return servers.map(viewMcpServer);
}
/**
@@ -90,21 +90,21 @@ export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpS
* 1 error". Only non-zero buckets are listed.
*/
export function summarizeMcpServers(servers: readonly McpServerInfo[]): string {
- if (servers.length === 0) return "No MCP servers";
- let connected = 0;
- let connecting = 0;
- let disconnected = 0;
- let errored = 0;
- for (const s of servers) {
- if (s.state === "connected") connected++;
- else if (s.state === "error") errored++;
- else if (s.state === "connecting") connecting++;
- else disconnected++;
- }
- const parts: string[] = [];
- if (connected > 0) parts.push(`${connected} connected`);
- if (connecting > 0) parts.push(`${connecting} connecting`);
- if (disconnected > 0) parts.push(`${disconnected} disconnected`);
- if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`);
- return parts.join(", ");
+ if (servers.length === 0) return "No MCP servers";
+ let connected = 0;
+ let connecting = 0;
+ let disconnected = 0;
+ let errored = 0;
+ for (const s of servers) {
+ if (s.state === "connected") connected++;
+ else if (s.state === "error") errored++;
+ else if (s.state === "connecting") connecting++;
+ else disconnected++;
+ }
+ const parts: string[] = [];
+ if (connected > 0) parts.push(`${connected} connected`);
+ if (connecting > 0) parts.push(`${connecting} connecting`);
+ if (disconnected > 0) parts.push(`${disconnected} disconnected`);
+ if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`);
+ return parts.join(", ");
}
diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte
index 9ac40ba..ad51496 100644
--- a/src/features/mcp/ui/McpStatusView.svelte
+++ b/src/features/mcp/ui/McpStatusView.svelte
@@ -1,131 +1,133 @@
<script lang="ts">
- import { untrack } from "svelte";
- import {
- type Badge,
- type LoadMcpStatus,
- type McpServerView,
- summarizeMcpServers,
- viewMcpServers,
- } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import {
+ type Badge,
+ type LoadMcpStatus,
+ type McpServerView,
+ summarizeMcpServers,
+ viewMcpServers,
+ } from "../logic/view-model";
- let {
- cwd,
- canView,
- load,
- }: {
- /** The active conversation's cwd — the trigger to (re)load when it changes. */
- cwd: string | null;
- /** Whether a real conversation is focused. */
- canView: boolean;
- load: LoadMcpStatus;
- } = $props();
+ let {
+ cwd,
+ canView,
+ load,
+ }: {
+ /** The active conversation's cwd — the trigger to (re)load when it changes. */
+ cwd: string | null;
+ /** Whether a real conversation is focused. */
+ canView: boolean;
+ load: LoadMcpStatus;
+ } = $props();
- const badgeClass: Record<Badge, string> = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- neutral: "badge-ghost",
- };
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
- let servers = $state<readonly McpServerView[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
- let loadedCwd = $state<string | null>(null);
- let hasLoaded = $state(false);
- let summary = $state("");
+ let servers = $state<readonly McpServerView[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
+ let loadedCwd = $state<string | null>(null);
+ let hasLoaded = $state(false);
+ let summary = $state("");
- async function refresh() {
- if (!canView) return;
- loading = true;
- error = null;
- const result = await load();
- loading = false;
- if (result === null) return;
- hasLoaded = true;
- if (result.ok) {
- servers = viewMcpServers(result.servers);
- summary = summarizeMcpServers(result.servers);
- loadedCwd = result.cwd;
- } else {
- error = result.error;
- }
- }
+ async function refresh() {
+ if (!canView) return;
+ loading = true;
+ error = null;
+ const result = await load();
+ loading = false;
+ if (result === null) return;
+ hasLoaded = true;
+ if (result.ok) {
+ servers = viewMcpServers(result.servers);
+ summary = summarizeMcpServers(result.servers);
+ loadedCwd = result.cwd;
+ } else {
+ error = result.error;
+ }
+ }
- // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET
- // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd`
- // resolves to the value we already loaded for.
- $effect(() => {
- const target = cwd;
- const can = canView;
- untrack(() => {
- if (!can) return;
- if (!hasLoaded || target !== loadedCwd) void refresh();
- });
- });
+ // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET
+ // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd`
+ // resolves to the value we already loaded for.
+ $effect(() => {
+ const target = cwd;
+ const can = canView;
+ untrack(() => {
+ if (!can) return;
+ if (!hasLoaded || target !== loadedCwd) void refresh();
+ });
+ });
</script>
<div class="flex flex-col gap-2">
- <div class="flex items-center justify-between gap-2">
- <span class="text-xs opacity-70">
- {#if loading}
- Resolving…
- {:else if hasLoaded && loadedCwd !== null}
- {summary}
- {:else}
- MCP servers
- {/if}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={!canView || loading}
- onclick={() => refresh()}
- aria-label="Refresh MCP server status"
- >
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Refresh
- {/if}
- </button>
- </div>
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs opacity-70">
+ {#if loading}
+ Resolving…
+ {:else if hasLoaded && loadedCwd !== null}
+ {summary}
+ {:else}
+ MCP servers
+ {/if}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={!canView || loading}
+ onclick={() => refresh()}
+ aria-label="Refresh MCP server status"
+ >
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
- {#if !canView}
- <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p>
- {:else if error}
- <p class="text-xs text-error">{error}</p>
- {:else if hasLoaded && loadedCwd === null}
- <p class="text-xs opacity-60">
- Set a working directory in the Model panel to enable MCP servers.
- </p>
- {:else if hasLoaded && servers.length === 0 && !loading}
- <p class="text-xs opacity-60">No MCP servers configured for this directory.</p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each servers as server (server.id)}
- <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium font-mono">{server.id}</span>
- <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
- {#if server.busy}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {server.statusLabel}
- </span>
- </div>
- <div class="flex items-center justify-between gap-2 text-xs opacity-60">
- {#if server.configSource}
- <span class="font-mono" title="Config source">{server.configSource}</span>
- {:else}
- <span></span>
- {/if}
- <span title="Discovered tools">{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span>
- </div>
- {#if server.error}
- <span class="font-mono text-xs text-error">{server.error}</span>
- {/if}
- </li>
- {/each}
- </ul>
- {/if}
+ {#if !canView}
+ <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p>
+ {:else if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if hasLoaded && loadedCwd === null}
+ <p class="text-xs opacity-60">
+ Set a working directory in the Model panel to enable MCP servers.
+ </p>
+ {:else if hasLoaded && servers.length === 0 && !loading}
+ <p class="text-xs opacity-60">No MCP servers configured for this directory.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each servers as server (server.id)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium font-mono">{server.id}</span>
+ <span class="badge badge-sm {badgeClass[server.badge]} gap-1">
+ {#if server.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ {server.statusLabel}
+ </span>
+ </div>
+ <div class="flex items-center justify-between gap-2 text-xs opacity-60">
+ {#if server.configSource}
+ <span class="font-mono" title="Config source">{server.configSource}</span>
+ {:else}
+ <span></span>
+ {/if}
+ <span title="Discovered tools"
+ >{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span
+ >
+ </div>
+ {#if server.error}
+ <span class="font-mono text-xs text-error">{server.error}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
</div>
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts
index 69e7cd0..3942e97 100644
--- a/src/features/settings/index.ts
+++ b/src/features/settings/index.ts
@@ -4,6 +4,6 @@ export { default as ChatLimitField } from "./ui/ChatLimitField.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "settings",
- description: "FE-local settings (chat limit)",
+ name: "settings",
+ description: "FE-local settings (chat limit)",
} as const;
diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts
index 65fcae9..93c4786 100644
--- a/src/features/settings/logic/view-model.test.ts
+++ b/src/features/settings/logic/view-model.test.ts
@@ -3,59 +3,59 @@ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
import { chatLimitChanged, parseChatLimit } from "./view-model";
describe("parseChatLimit", () => {
- it("parses a plain integer", () => {
- expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 });
- expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 });
- });
-
- it("trims surrounding whitespace", () => {
- expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 });
- });
-
- it("floors a decimal", () => {
- expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 });
- });
-
- it("clamps below the floor to MIN_CHAT_LIMIT", () => {
- expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
- expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
- expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
- });
-
- it("clamps above the ceiling to MAX_CHAT_LIMIT", () => {
- expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT });
- });
-
- it("rejects an empty string", () => {
- expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) });
- expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) });
- });
-
- it("rejects non-numeric input", () => {
- expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) });
- expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) });
- });
+ it("parses a plain integer", () => {
+ expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 });
+ expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 });
+ });
+
+ it("trims surrounding whitespace", () => {
+ expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 });
+ });
+
+ it("floors a decimal", () => {
+ expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 });
+ });
+
+ it("clamps below the floor to MIN_CHAT_LIMIT", () => {
+ expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ });
+
+ it("clamps above the ceiling to MAX_CHAT_LIMIT", () => {
+ expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT });
+ });
+
+ it("rejects an empty string", () => {
+ expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) });
+ expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) });
+ });
+
+ it("rejects non-numeric input", () => {
+ expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) });
+ expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) });
+ });
});
describe("chatLimitChanged", () => {
- it("is false when the typed value normalizes to the current limit", () => {
- expect(chatLimitChanged("256", 256)).toBe(false);
- });
-
- it("is true when the typed value differs", () => {
- expect(chatLimitChanged("100", 256)).toBe(true);
- expect(chatLimitChanged("256", 100)).toBe(true);
- });
-
- it("is false for empty / invalid input (nothing submittable)", () => {
- expect(chatLimitChanged("", 256)).toBe(false);
- expect(chatLimitChanged("abc", 256)).toBe(false);
- });
-
- it("is false when the typed value clamps to the current limit", () => {
- // 5 clamps to MIN (10); current is already 10 → no change.
- expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false);
- // A huge value clamps to MAX; current is already MAX → no change.
- expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false);
- });
+ it("is false when the typed value normalizes to the current limit", () => {
+ expect(chatLimitChanged("256", 256)).toBe(false);
+ });
+
+ it("is true when the typed value differs", () => {
+ expect(chatLimitChanged("100", 256)).toBe(true);
+ expect(chatLimitChanged("256", 100)).toBe(true);
+ });
+
+ it("is false for empty / invalid input (nothing submittable)", () => {
+ expect(chatLimitChanged("", 256)).toBe(false);
+ expect(chatLimitChanged("abc", 256)).toBe(false);
+ });
+
+ it("is false when the typed value clamps to the current limit", () => {
+ // 5 clamps to MIN (10); current is already 10 → no change.
+ expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false);
+ // A huge value clamps to MAX; current is already MAX → no change.
+ expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false);
+ });
});
diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts
index caf76ee..73f6d17 100644
--- a/src/features/settings/logic/view-model.ts
+++ b/src/features/settings/logic/view-model.ts
@@ -17,8 +17,8 @@ import { normalizeChatLimit } from "../../../core/chunks";
/** Outcome of persisting a chat-limit setting. */
export type ChatLimitSaveResult =
- | { readonly ok: true; readonly chatLimit: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
@@ -26,8 +26,8 @@ export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
/** Result of parsing a typed chat-limit string. */
export type ChatLimitParse =
- | { readonly ok: true; readonly value: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly value: number }
+ | { readonly ok: false; readonly error: string };
/**
* Parse a typed chat-limit string into a normalized limit (floored + clamped to
@@ -37,15 +37,15 @@ export type ChatLimitParse =
* from user typing.
*/
export function parseChatLimit(raw: string): ChatLimitParse {
- const trimmed = raw.trim();
- if (trimmed.length === 0) {
- return { ok: false, error: "Enter a number." };
- }
- const n = Number(trimmed);
- if (!Number.isFinite(n)) {
- return { ok: false, error: "Must be a number." };
- }
- return { ok: true, value: normalizeChatLimit(n) };
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) {
+ return { ok: false, error: "Enter a number." };
+ }
+ const n = Number(trimmed);
+ if (!Number.isFinite(n)) {
+ return { ok: false, error: "Must be a number." };
+ }
+ return { ok: true, value: normalizeChatLimit(n) };
}
/**
@@ -54,7 +54,7 @@ export function parseChatLimit(raw: string): ChatLimitParse {
* dirty-check for the input — it must NOT mutate or clamp, only compare.
*/
export function chatLimitChanged(typed: string, current: number): boolean {
- const parsed = parseChatLimit(typed);
- if (!parsed.ok) return false;
- return parsed.value !== current;
+ const parsed = parseChatLimit(typed);
+ if (!parsed.ok) return false;
+ return parsed.value !== current;
}
diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte
index 9b9911a..502212f 100644
--- a/src/features/settings/ui/ChatLimitField.svelte
+++ b/src/features/settings/ui/ChatLimitField.svelte
@@ -1,104 +1,104 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
- import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
+ import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
- let {
- chatLimit,
- save,
- }: {
- /** The persisted chat limit (max loaded chunks per conversation). */
- chatLimit: number;
- save: SaveChatLimit;
- } = $props();
+ let {
+ chatLimit,
+ save,
+ }: {
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ chatLimit: number;
+ save: SaveChatLimit;
+ } = $props();
- // Seed from the prop; the $effect below re-seeds on external changes (a live
- // apply from elsewhere) but only while the field is untouched, so an in-flight
- // change can't clobber what the user typed.
- let value = $state("");
- let lastSeed = $state("");
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
- let savedValue = $state<number | null>(null);
+ // Seed from the prop; the $effect below re-seeds on external changes (a live
+ // apply from elsewhere) but only while the field is untouched, so an in-flight
+ // change can't clobber what the user typed.
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let savedValue = $state<number | null>(null);
- $effect(() => {
- const incoming = String(chatLimit);
- untrack(() => {
- if (value === lastSeed) value = incoming;
- lastSeed = incoming;
- });
- });
+ $effect(() => {
+ const incoming = String(chatLimit);
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
- const dirty = $derived(chatLimitChanged(value, chatLimit));
+ const dirty = $derived(chatLimitChanged(value, chatLimit));
- async function handleSave() {
- if (saving || !dirty) return;
- const parsed = parseChatLimit(value);
- if (!parsed.ok) {
- error = parsed.error;
- return;
- }
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(parsed.value);
- saving = false;
- if (result.ok) {
- justSaved = true;
- savedValue = result.chatLimit;
- // Reflect the clamped / persisted value back into the input immediately
- // (the prop will also re-assert it via the effect above).
- value = String(result.chatLimit);
- lastSeed = value;
- } else {
- error = result.error;
- }
- }
+ async function handleSave() {
+ if (saving || !dirty) return;
+ const parsed = parseChatLimit(value);
+ if (!parsed.ok) {
+ error = parsed.error;
+ return;
+ }
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(parsed.value);
+ saving = false;
+ if (result.ok) {
+ justSaved = true;
+ savedValue = result.chatLimit;
+ // Reflect the clamped / persisted value back into the input immediately
+ // (the prop will also re-assert it via the effect above).
+ value = String(result.chatLimit);
+ lastSeed = value;
+ } else {
+ error = result.error;
+ }
+ }
- function onInput() {
- justSaved = false;
- error = null;
- }
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
- <div class="flex items-center gap-2">
- <input
- type="text"
- inputmode="numeric"
- class="input input-bordered input-sm w-full font-mono text-xs"
- placeholder={String(chatLimit)}
- bind:value
- disabled={saving}
- oninput={onInput}
- onkeydown={(e) => {
- if (e.key === "Enter") handleSave();
- }}
- aria-label="Chat limit"
- />
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={saving || !dirty}
- onclick={handleSave}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved && !dirty}
- <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
- {:else}
- <p class="text-xs opacity-50">
- Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads
- older history; raise it and use "Show earlier" to page back in.
- </p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={String(chatLimit)}
+ bind:value
+ disabled={saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Chat limit"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads
+ older history; raise it and use "Show earlier" to page back in.
+ </p>
+ {/if}
</div>
diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts
index 73bc449..8f8da25 100644
--- a/src/features/settings/ui/ChatLimitField.test.ts
+++ b/src/features/settings/ui/ChatLimitField.test.ts
@@ -6,84 +6,84 @@ import ChatLimitField from "./ChatLimitField.svelte";
// A fake save that resolves ok with the value it was given.
function fakeSave(): {
- saves: number[];
- last: number | null;
- impl: (value: number) => Promise<ChatLimitSaveResult>;
+ saves: number[];
+ last: number | null;
+ impl: (value: number) => Promise<ChatLimitSaveResult>;
} {
- const saves: number[] = [];
- return {
- saves,
- last: null,
- impl: async (value: number) => {
- saves.push(value);
- return { ok: true, chatLimit: value };
- },
- };
+ const saves: number[] = [];
+ return {
+ saves,
+ last: null,
+ impl: async (value: number) => {
+ saves.push(value);
+ return { ok: true, chatLimit: value };
+ },
+ };
}
describe("ChatLimitField", () => {
- it("seeds the input from the persisted limit and disables Set while unchanged", () => {
- render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } });
- expect(screen.getByLabelText("Chat limit")).toHaveValue("256");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
- });
+ it("seeds the input from the persisted limit and disables Set while unchanged", () => {
+ render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } });
+ expect(screen.getByLabelText("Chat limit")).toHaveValue("256");
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
- it("enables Set when the typed value differs (regression: number-binding coercion)", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+ it("enables Set when the typed value differs (regression: number-binding coercion)", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "10");
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "10");
- expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
- });
+ expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
+ });
- it("keeps Set disabled for non-numeric input", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+ it("keeps Set disabled for non-numeric input", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "abc");
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "abc");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
- });
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
- it("clicking Set calls save with the parsed value and shows the confirmation", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- const { rerender } = render(ChatLimitField, {
- props: { chatLimit: 256, save: save.impl },
- });
+ it("clicking Set calls save with the parsed value and shows the confirmation", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ const { rerender } = render(ChatLimitField, {
+ props: { chatLimit: 256, save: save.impl },
+ });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "50");
- await user.click(screen.getByRole("button", { name: "Set" }));
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "50");
+ await user.click(screen.getByRole("button", { name: "Set" }));
- expect(save.saves).toEqual([50]);
- // In the real app the reactive `chatLimit` prop updates to the saved value
- // (the store sets it synchronously), which clears `dirty` and reveals the
- // "Saved" badge. Rerender simulates that propagation.
- rerender({ chatLimit: 50, save: save.impl });
- expect(screen.getByText(/Saved/i)).toBeInTheDocument();
- });
+ expect(save.saves).toEqual([50]);
+ // In the real app the reactive `chatLimit` prop updates to the saved value
+ // (the store sets it synchronously), which clears `dirty` and reveals the
+ // "Saved" badge. Rerender simulates that propagation.
+ rerender({ chatLimit: 50, save: save.impl });
+ expect(screen.getByText(/Saved/i)).toBeInTheDocument();
+ });
- it("clamps a below-floor value when saving", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+ it("clamps a below-floor value when saving", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "5"); // clamps to MIN (10)
- await user.click(screen.getByRole("button", { name: "Set" }));
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "5"); // clamps to MIN (10)
+ await user.click(screen.getByRole("button", { name: "Set" }));
- // The save port receives the clamped value (the view-model clamps before save).
- expect(save.saves).toEqual([10]);
- expect(input).toHaveValue("10");
- });
+ // The save port receives the clamped value (the view-model clamps before save).
+ expect(save.saves).toEqual([10]);
+ expect(input).toHaveValue("10");
+ });
});
diff --git a/src/features/smart-scroll/index.ts b/src/features/smart-scroll/index.ts
index 0d30257..73b11d6 100644
--- a/src/features/smart-scroll/index.ts
+++ b/src/features/smart-scroll/index.ts
@@ -1,17 +1,17 @@
export type {
- ScrollCommand,
- ScrollGeometry,
- SmartScrollResult,
- SmartScrollState,
+ ScrollCommand,
+ ScrollGeometry,
+ SmartScrollResult,
+ SmartScrollState,
} from "./logic/smart-scroll";
export {
- createSmartScrollState,
- isNearBottom,
- NEAR_BOTTOM_THRESHOLD,
- onContentChange,
- onReset,
- onResume,
- onScroll,
+ createSmartScrollState,
+ isNearBottom,
+ NEAR_BOTTOM_THRESHOLD,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
} from "./logic/smart-scroll";
export type { SmartScrollController } from "./ui/controller.svelte";
export { createSmartScrollController } from "./ui/controller.svelte";
@@ -19,7 +19,7 @@ export { default as ScrollToBottom } from "./ui/ScrollToBottom.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "smart-scroll",
- description:
- "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up",
+ name: "smart-scroll",
+ description:
+ "Keeps the transcript pinned to the bottom while it streams, unless the reader scrolls up",
} as const;
diff --git a/src/features/smart-scroll/logic/smart-scroll.test.ts b/src/features/smart-scroll/logic/smart-scroll.test.ts
index fc3e3d1..94ba1d7 100644
--- a/src/features/smart-scroll/logic/smart-scroll.test.ts
+++ b/src/features/smart-scroll/logic/smart-scroll.test.ts
@@ -1,103 +1,103 @@
import { describe, expect, it } from "vitest";
import {
- createSmartScrollState,
- isNearBottom,
- NEAR_BOTTOM_THRESHOLD,
- onContentChange,
- onReset,
- onResume,
- onScroll,
- type ScrollGeometry,
+ createSmartScrollState,
+ isNearBottom,
+ NEAR_BOTTOM_THRESHOLD,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+ type ScrollGeometry,
} from "./smart-scroll";
// A viewport 100px tall over 1000px of content: scrollTop 900 == pinned to bottom.
const atBottom: ScrollGeometry = { scrollTop: 900, scrollHeight: 1000, clientHeight: 100 };
const nearBottom: ScrollGeometry = {
- scrollTop: 900 - NEAR_BOTTOM_THRESHOLD,
- scrollHeight: 1000,
- clientHeight: 100,
+ scrollTop: 900 - NEAR_BOTTOM_THRESHOLD,
+ scrollHeight: 1000,
+ clientHeight: 100,
};
const scrolledUp: ScrollGeometry = { scrollTop: 200, scrollHeight: 1000, clientHeight: 100 };
describe("isNearBottom", () => {
- it("is true exactly at the bottom", () => {
- expect(isNearBottom(atBottom)).toBe(true);
- });
+ it("is true exactly at the bottom", () => {
+ expect(isNearBottom(atBottom)).toBe(true);
+ });
- it("is true within the threshold of the bottom", () => {
- expect(isNearBottom(nearBottom)).toBe(true);
- });
+ it("is true within the threshold of the bottom", () => {
+ expect(isNearBottom(nearBottom)).toBe(true);
+ });
- it("is false just beyond the threshold", () => {
- expect(
- isNearBottom({
- scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1,
- scrollHeight: 1000,
- clientHeight: 100,
- }),
- ).toBe(false);
- });
+ it("is false just beyond the threshold", () => {
+ expect(
+ isNearBottom({
+ scrollTop: 900 - NEAR_BOTTOM_THRESHOLD - 1,
+ scrollHeight: 1000,
+ clientHeight: 100,
+ }),
+ ).toBe(false);
+ });
- it("is false when scrolled well up", () => {
- expect(isNearBottom(scrolledUp)).toBe(false);
- });
+ it("is false when scrolled well up", () => {
+ expect(isNearBottom(scrolledUp)).toBe(false);
+ });
- it("honours a custom threshold", () => {
- const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 };
- expect(isNearBottom(geom, 50)).toBe(false);
- expect(isNearBottom(geom, 150)).toBe(true);
- });
+ it("honours a custom threshold", () => {
+ const geom: ScrollGeometry = { scrollTop: 800, scrollHeight: 1000, clientHeight: 100 };
+ expect(isNearBottom(geom, 50)).toBe(false);
+ expect(isNearBottom(geom, 150)).toBe(true);
+ });
});
describe("smart-scroll reducer", () => {
- it("starts stuck and hides the button", () => {
- const s = createSmartScrollState();
- expect(s.stuck).toBe(true);
- });
+ it("starts stuck and hides the button", () => {
+ const s = createSmartScrollState();
+ expect(s.stuck).toBe(true);
+ });
- it("onScroll up unsticks and shows the button, with no command", () => {
- const r = onScroll(createSmartScrollState(), scrolledUp);
- expect(r.state.stuck).toBe(false);
- expect(r.showButton).toBe(true);
- expect(r.command).toBeNull();
- });
+ it("onScroll up unsticks and shows the button, with no command", () => {
+ const r = onScroll(createSmartScrollState(), scrolledUp);
+ expect(r.state.stuck).toBe(false);
+ expect(r.showButton).toBe(true);
+ expect(r.command).toBeNull();
+ });
- it("onScroll back to the bottom re-sticks and hides the button", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onScroll(up, atBottom);
- expect(r.state.stuck).toBe(true);
- expect(r.showButton).toBe(false);
- expect(r.command).toBeNull();
- });
+ it("onScroll back to the bottom re-sticks and hides the button", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onScroll(up, atBottom);
+ expect(r.state.stuck).toBe(true);
+ expect(r.showButton).toBe(false);
+ expect(r.command).toBeNull();
+ });
- it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => {
- const r = onContentChange(createSmartScrollState(), atBottom);
- expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
- expect(r.state.stuck).toBe(true);
- });
+ it("onContentChange while stuck emits a NON-animated scroll (keep up with the stream)", () => {
+ const r = onContentChange(createSmartScrollState(), atBottom);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
+ expect(r.state.stuck).toBe(true);
+ });
- it("onContentChange while unstuck emits NO command (leave the reader in place)", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onContentChange(up, scrolledUp);
- expect(r.command).toBeNull();
- expect(r.state.stuck).toBe(false);
- expect(r.showButton).toBe(true);
- });
+ it("onContentChange while unstuck emits NO command (leave the reader in place)", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onContentChange(up, scrolledUp);
+ expect(r.command).toBeNull();
+ expect(r.state.stuck).toBe(false);
+ expect(r.showButton).toBe(true);
+ });
- it("onResume re-sticks and emits an ANIMATED scroll", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onResume(up);
- expect(r.state.stuck).toBe(true);
- expect(r.showButton).toBe(false);
- expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true });
- });
+ it("onResume re-sticks and emits an ANIMATED scroll", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onResume(up);
+ expect(r.state.stuck).toBe(true);
+ expect(r.showButton).toBe(false);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: true });
+ });
- it("onReset returns to stuck and snaps (non-animated) to the bottom", () => {
- const up = onScroll(createSmartScrollState(), scrolledUp).state;
- const r = onReset();
- void up;
- expect(r.state.stuck).toBe(true);
- expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
- expect(r.showButton).toBe(false);
- });
+ it("onReset returns to stuck and snaps (non-animated) to the bottom", () => {
+ const up = onScroll(createSmartScrollState(), scrolledUp).state;
+ const r = onReset();
+ void up;
+ expect(r.state.stuck).toBe(true);
+ expect(r.command).toEqual({ kind: "scroll-to-bottom", animate: false });
+ expect(r.showButton).toBe(false);
+ });
});
diff --git a/src/features/smart-scroll/logic/smart-scroll.ts b/src/features/smart-scroll/logic/smart-scroll.ts
index 021b3fe..4a04812 100644
--- a/src/features/smart-scroll/logic/smart-scroll.ts
+++ b/src/features/smart-scroll/logic/smart-scroll.ts
@@ -6,12 +6,12 @@
/** A snapshot of a scroll container's vertical geometry (in CSS pixels). */
export interface ScrollGeometry {
- /** Current scroll offset from the top. */
- readonly scrollTop: number;
- /** Total scrollable content height. */
- readonly scrollHeight: number;
- /** Visible viewport height. */
- readonly clientHeight: number;
+ /** Current scroll offset from the top. */
+ readonly scrollTop: number;
+ /** Total scrollable content height. */
+ readonly scrollHeight: number;
+ /** Visible viewport height. */
+ readonly clientHeight: number;
}
/** Distance (px) from the bottom within which we still consider the view "at bottom". */
@@ -19,43 +19,43 @@ export const NEAR_BOTTOM_THRESHOLD = 64;
/** True when the viewport is within `threshold` px of the content's bottom edge. */
export function isNearBottom(
- geom: ScrollGeometry,
- threshold: number = NEAR_BOTTOM_THRESHOLD,
+ geom: ScrollGeometry,
+ threshold: number = NEAR_BOTTOM_THRESHOLD,
): boolean {
- return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold;
+ return geom.scrollHeight - geom.scrollTop - geom.clientHeight <= threshold;
}
/** A scroll the shell should perform on the real element. */
export interface ScrollCommand {
- readonly kind: "scroll-to-bottom";
- /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */
- readonly animate: boolean;
+ readonly kind: "scroll-to-bottom";
+ /** Smooth-scroll (a deliberate resume) vs. jump (keeping up with a stream). */
+ readonly animate: boolean;
}
export interface SmartScrollState {
- /**
- * Whether the view is currently following the bottom. While `stuck`, new
- * content keeps the view pinned to the bottom; once the user scrolls up it
- * goes false and stays false until they return to the bottom (or resume).
- */
- readonly stuck: boolean;
+ /**
+ * Whether the view is currently following the bottom. While `stuck`, new
+ * content keeps the view pinned to the bottom; once the user scrolls up it
+ * goes false and stays false until they return to the bottom (or resume).
+ */
+ readonly stuck: boolean;
}
/** A reducer step's result: the next state, an optional command, and whether to show the button. */
export interface SmartScrollResult {
- readonly state: SmartScrollState;
- readonly command: ScrollCommand | null;
- /** Show the "scroll to bottom" affordance exactly when not stuck. */
- readonly showButton: boolean;
+ readonly state: SmartScrollState;
+ readonly command: ScrollCommand | null;
+ /** Show the "scroll to bottom" affordance exactly when not stuck. */
+ readonly showButton: boolean;
}
/** Initial state — start stuck so the first content snaps to the bottom. */
export function createSmartScrollState(): SmartScrollState {
- return { stuck: true };
+ return { stuck: true };
}
function result(state: SmartScrollState, command: ScrollCommand | null): SmartScrollResult {
- return { state, command, showButton: !state.stuck };
+ return { state, command, showButton: !state.stuck };
}
/**
@@ -64,7 +64,7 @@ function result(state: SmartScrollState, command: ScrollCommand | null): SmartSc
* command — reacting to the user's own scroll with a scroll would fight them.
*/
export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartScrollResult {
- return result({ stuck: isNearBottom(geom) }, null);
+ return result({ stuck: isNearBottom(geom) }, null);
}
/**
@@ -73,7 +73,7 @@ export function onScroll(_state: SmartScrollState, geom: ScrollGeometry): SmartS
* they are. State is unchanged — content growth alone never flips `stuck`.
*/
export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry): SmartScrollResult {
- return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null);
+ return result(state, state.stuck ? { kind: "scroll-to-bottom", animate: false } : null);
}
/**
@@ -81,7 +81,7 @@ export function onContentChange(state: SmartScrollState, _geom: ScrollGeometry):
* emit an animated scroll.
*/
export function onResume(_state: SmartScrollState): SmartScrollResult {
- return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
+ return result({ stuck: true }, { kind: "scroll-to-bottom", animate: true });
}
/**
@@ -89,5 +89,5 @@ export function onResume(_state: SmartScrollState): SmartScrollResult {
* Reset to stuck and snap (non-animated) to the bottom of the new content.
*/
export function onReset(): SmartScrollResult {
- return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false });
+ return result(createSmartScrollState(), { kind: "scroll-to-bottom", animate: false });
}
diff --git a/src/features/smart-scroll/ui/ScrollToBottom.svelte b/src/features/smart-scroll/ui/ScrollToBottom.svelte
index 6fbd326..38ec1f5 100644
--- a/src/features/smart-scroll/ui/ScrollToBottom.svelte
+++ b/src/features/smart-scroll/ui/ScrollToBottom.svelte
@@ -1,36 +1,36 @@
<script lang="ts">
- // Thin affordance: a floating "scroll to bottom" button shown while the reader
- // has scrolled up. Holds no logic — `show` and `onResume` come from the
- // smart-scroll controller.
- let {
- show,
- onResume,
- }: {
- show: boolean;
- onResume: () => void;
- } = $props();
+ // Thin affordance: a floating "scroll to bottom" button shown while the reader
+ // has scrolled up. Holds no logic — `show` and `onResume` come from the
+ // smart-scroll controller.
+ let {
+ show,
+ onResume,
+ }: {
+ show: boolean;
+ onResume: () => void;
+ } = $props();
</script>
<button
- type="button"
- class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
- class:opacity-0={!show}
- class:pointer-events-none={!show}
- class:opacity-100={show}
- onclick={onResume}
- aria-label="Scroll to bottom"
- aria-hidden={!show}
- tabindex={show ? 0 : -1}
+ type="button"
+ class="btn btn-circle btn-sm absolute bottom-4 left-1/2 -translate-x-1/2 shadow-lg transition-opacity duration-200"
+ class:opacity-0={!show}
+ class:pointer-events-none={!show}
+ class:opacity-100={show}
+ onclick={onResume}
+ aria-label="Scroll to bottom"
+ aria-hidden={!show}
+ tabindex={show ? 0 : -1}
>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2.5"
- class="size-4"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
- </svg>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ class="size-4"
+ aria-hidden="true"
+ >
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
+ </svg>
</button>
diff --git a/src/features/smart-scroll/ui/controller.svelte.ts b/src/features/smart-scroll/ui/controller.svelte.ts
index dbe65d1..fee6561 100644
--- a/src/features/smart-scroll/ui/controller.svelte.ts
+++ b/src/features/smart-scroll/ui/controller.svelte.ts
@@ -7,134 +7,134 @@
// it on unmount.
import {
- createSmartScrollState,
- onContentChange,
- onReset,
- onResume,
- onScroll,
- type ScrollCommand,
- type ScrollGeometry,
- type SmartScrollResult,
- type SmartScrollState,
+ createSmartScrollState,
+ onContentChange,
+ onReset,
+ onResume,
+ onScroll,
+ type ScrollCommand,
+ type ScrollGeometry,
+ type SmartScrollResult,
+ type SmartScrollState,
} from "../logic/smart-scroll";
export interface SmartScrollController {
- /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */
- readonly showButton: boolean;
- /**
- * Non-reactive point-in-time query: is the view stuck to the bottom right now?
- * For imperative callers (e.g. the chat-limit unload gate) that poll at event
- * time rather than subscribing — reads the reducer state, not a rune.
- */
- isAtBottom(): boolean;
- /**
- * Attach to the scroll container; returns a teardown to call on unmount.
- * Pass the inner CONTENT element to also follow height changes that aren't a
- * transcript update (async markdown/highlight, image loads, a collapse toggling,
- * viewport reflow) via a ResizeObserver.
- */
- attach(el: HTMLElement, content?: HTMLElement): () => void;
- /**
- * Notify that the transcript content changed (a streamed delta / new message).
- * While stuck, keeps the view pinned to the bottom.
- */
- contentChanged(): void;
- /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */
- reset(): void;
- /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */
- resume(): void;
+ /** Reactive: show the "scroll to bottom" affordance (the user has scrolled up). */
+ readonly showButton: boolean;
+ /**
+ * Non-reactive point-in-time query: is the view stuck to the bottom right now?
+ * For imperative callers (e.g. the chat-limit unload gate) that poll at event
+ * time rather than subscribing — reads the reducer state, not a rune.
+ */
+ isAtBottom(): boolean;
+ /**
+ * Attach to the scroll container; returns a teardown to call on unmount.
+ * Pass the inner CONTENT element to also follow height changes that aren't a
+ * transcript update (async markdown/highlight, image loads, a collapse toggling,
+ * viewport reflow) via a ResizeObserver.
+ */
+ attach(el: HTMLElement, content?: HTMLElement): () => void;
+ /**
+ * Notify that the transcript content changed (a streamed delta / new message).
+ * While stuck, keeps the view pinned to the bottom.
+ */
+ contentChanged(): void;
+ /** Reset for a new transcript context (e.g. conversation switch): snap to bottom. */
+ reset(): void;
+ /** The user clicked the affordance: re-stick and smooth-scroll to the bottom. */
+ resume(): void;
}
function geometryOf(el: HTMLElement): ScrollGeometry {
- return {
- scrollTop: el.scrollTop,
- scrollHeight: el.scrollHeight,
- clientHeight: el.clientHeight,
- };
+ return {
+ scrollTop: el.scrollTop,
+ scrollHeight: el.scrollHeight,
+ clientHeight: el.clientHeight,
+ };
}
export function createSmartScrollController(): SmartScrollController {
- let state: SmartScrollState = createSmartScrollState();
- let showButton = $state(false);
- let el: HTMLElement | null = null;
- // True while WE drive a programmatic scroll, so the resulting `scroll` event
- // doesn't get misread as the user scrolling up. Cleared on `scrollend`.
- let selfScrolling = false;
-
- function run(command: ScrollCommand | null): void {
- if (!command || !el) return;
- selfScrolling = true;
- el.scrollTo({
- top: el.scrollHeight,
- behavior: command.animate ? "smooth" : "instant",
- });
- }
-
- function apply(r: SmartScrollResult): void {
- state = r.state;
- showButton = r.showButton;
- run(r.command);
- }
-
- function handleScroll(): void {
- if (!el || selfScrolling) return;
- apply(onScroll(state, geometryOf(el)));
- }
-
- function handleScrollEnd(): void {
- selfScrolling = false;
- }
-
- return {
- get showButton(): boolean {
- return showButton;
- },
-
- isAtBottom(): boolean {
- return state.stuck;
- },
-
- attach(node: HTMLElement, content?: HTMLElement): () => void {
- el = node;
- node.addEventListener("scroll", handleScroll, { passive: true });
- node.addEventListener("scrollend", handleScrollEnd);
-
- // A ResizeObserver keeps the view pinned through height changes that are
- // NOT a transcript update — async markdown/syntax-highlight, image loads, a
- // collapse toggling, font swaps, viewport reflow — which a content-count
- // signal can't see. Observe the CONTENT (it grows) and the container (it
- // changes on viewport resize). Routed through `onContentChange`, so it only
- // scrolls while stuck and never fights the reader. The `selfScrolling` guard
- // (and the fact that scrolling doesn't resize content) prevents any loop.
- let ro: ResizeObserver | null = null;
- if (typeof ResizeObserver !== "undefined") {
- ro = new ResizeObserver(() => {
- if (!el || selfScrolling) return;
- apply(onContentChange(state, geometryOf(el)));
- });
- if (content) ro.observe(content);
- ro.observe(node);
- }
-
- return () => {
- node.removeEventListener("scroll", handleScroll);
- node.removeEventListener("scrollend", handleScrollEnd);
- ro?.disconnect();
- if (el === node) el = null;
- };
- },
-
- contentChanged(): void {
- if (!el) return;
- apply(onContentChange(state, geometryOf(el)));
- },
-
- reset(): void {
- apply(onReset());
- },
-
- resume(): void {
- apply(onResume(state));
- },
- };
+ let state: SmartScrollState = createSmartScrollState();
+ let showButton = $state(false);
+ let el: HTMLElement | null = null;
+ // True while WE drive a programmatic scroll, so the resulting `scroll` event
+ // doesn't get misread as the user scrolling up. Cleared on `scrollend`.
+ let selfScrolling = false;
+
+ function run(command: ScrollCommand | null): void {
+ if (!command || !el) return;
+ selfScrolling = true;
+ el.scrollTo({
+ top: el.scrollHeight,
+ behavior: command.animate ? "smooth" : "instant",
+ });
+ }
+
+ function apply(r: SmartScrollResult): void {
+ state = r.state;
+ showButton = r.showButton;
+ run(r.command);
+ }
+
+ function handleScroll(): void {
+ if (!el || selfScrolling) return;
+ apply(onScroll(state, geometryOf(el)));
+ }
+
+ function handleScrollEnd(): void {
+ selfScrolling = false;
+ }
+
+ return {
+ get showButton(): boolean {
+ return showButton;
+ },
+
+ isAtBottom(): boolean {
+ return state.stuck;
+ },
+
+ attach(node: HTMLElement, content?: HTMLElement): () => void {
+ el = node;
+ node.addEventListener("scroll", handleScroll, { passive: true });
+ node.addEventListener("scrollend", handleScrollEnd);
+
+ // A ResizeObserver keeps the view pinned through height changes that are
+ // NOT a transcript update — async markdown/syntax-highlight, image loads, a
+ // collapse toggling, font swaps, viewport reflow — which a content-count
+ // signal can't see. Observe the CONTENT (it grows) and the container (it
+ // changes on viewport resize). Routed through `onContentChange`, so it only
+ // scrolls while stuck and never fights the reader. The `selfScrolling` guard
+ // (and the fact that scrolling doesn't resize content) prevents any loop.
+ let ro: ResizeObserver | null = null;
+ if (typeof ResizeObserver !== "undefined") {
+ ro = new ResizeObserver(() => {
+ if (!el || selfScrolling) return;
+ apply(onContentChange(state, geometryOf(el)));
+ });
+ if (content) ro.observe(content);
+ ro.observe(node);
+ }
+
+ return () => {
+ node.removeEventListener("scroll", handleScroll);
+ node.removeEventListener("scrollend", handleScrollEnd);
+ ro?.disconnect();
+ if (el === node) el = null;
+ };
+ },
+
+ contentChanged(): void {
+ if (!el) return;
+ apply(onContentChange(state, geometryOf(el)));
+ },
+
+ reset(): void {
+ apply(onReset());
+ },
+
+ resume(): void {
+ apply(onResume(state));
+ },
+ };
}
diff --git a/src/features/smart-scroll/ui/controller.test.ts b/src/features/smart-scroll/ui/controller.test.ts
index 614f4b0..906cc91 100644
--- a/src/features/smart-scroll/ui/controller.test.ts
+++ b/src/features/smart-scroll/ui/controller.test.ts
@@ -5,168 +5,168 @@ import { createSmartScrollController } from "./controller.svelte";
// geometry, scrollTo, and add/removeEventListener for "scroll"/"scrollend".
// Faking this outermost edge is the sanctioned mock (no internal modules mocked).
function createFakeScrollEl(opts?: { scrollHeight?: number; clientHeight?: number }) {
- const listeners = new Map<string, Set<EventListener>>();
- const el = {
- scrollTop: 0,
- scrollHeight: opts?.scrollHeight ?? 1000,
- clientHeight: opts?.clientHeight ?? 100,
- scrollTo: vi.fn((arg: ScrollToOptions) => {
- // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend.
- el.scrollTop = (arg.top ?? 0) - 0;
- if (arg.behavior !== "smooth") {
- fire("scroll");
- fire("scrollend");
- }
- }),
- addEventListener: (type: string, fn: EventListener) => {
- if (!listeners.has(type)) listeners.set(type, new Set());
- listeners.get(type)?.add(fn);
- },
- removeEventListener: (type: string, fn: EventListener) => {
- listeners.get(type)?.delete(fn);
- },
- };
- function fire(type: string): void {
- for (const fn of listeners.get(type) ?? []) fn(new Event(type));
- }
- // Simulate the USER scrolling to a given offset (fires scroll, not self-driven).
- function userScrollTo(top: number): void {
- el.scrollTop = top;
- fire("scroll");
- }
- return {
- el: el as unknown as HTMLElement,
- scrollTo: el.scrollTo,
- fire,
- userScrollTo,
- listenerCount: () => listeners,
- };
+ const listeners = new Map<string, Set<EventListener>>();
+ const el = {
+ scrollTop: 0,
+ scrollHeight: opts?.scrollHeight ?? 1000,
+ clientHeight: opts?.clientHeight ?? 100,
+ scrollTo: vi.fn((arg: ScrollToOptions) => {
+ // Emulate the browser: jump scrollTop, then (for "instant") fire scrollend.
+ el.scrollTop = (arg.top ?? 0) - 0;
+ if (arg.behavior !== "smooth") {
+ fire("scroll");
+ fire("scrollend");
+ }
+ }),
+ addEventListener: (type: string, fn: EventListener) => {
+ if (!listeners.has(type)) listeners.set(type, new Set());
+ listeners.get(type)?.add(fn);
+ },
+ removeEventListener: (type: string, fn: EventListener) => {
+ listeners.get(type)?.delete(fn);
+ },
+ };
+ function fire(type: string): void {
+ for (const fn of listeners.get(type) ?? []) fn(new Event(type));
+ }
+ // Simulate the USER scrolling to a given offset (fires scroll, not self-driven).
+ function userScrollTo(top: number): void {
+ el.scrollTop = top;
+ fire("scroll");
+ }
+ return {
+ el: el as unknown as HTMLElement,
+ scrollTo: el.scrollTo,
+ fire,
+ userScrollTo,
+ listenerCount: () => listeners,
+ };
}
describe("smart-scroll controller", () => {
- it("starts with the button hidden", () => {
- const c = createSmartScrollController();
- expect(c.showButton).toBe(false);
- });
+ it("starts with the button hidden", () => {
+ const c = createSmartScrollController();
+ expect(c.showButton).toBe(false);
+ });
- it("contentChanged while stuck scrolls to the bottom instantly", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- c.contentChanged();
- expect(fake.scrollTo).toHaveBeenCalledWith({
- top: 1000,
- behavior: "instant",
- });
- expect(c.showButton).toBe(false);
- });
+ it("contentChanged while stuck scrolls to the bottom instantly", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ c.contentChanged();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "instant",
+ });
+ expect(c.showButton).toBe(false);
+ });
- it("a user scroll up shows the button and stops auto-following", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- fake.userScrollTo(200); // far from the bottom
- expect(c.showButton).toBe(true);
+ it("a user scroll up shows the button and stops auto-following", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200); // far from the bottom
+ expect(c.showButton).toBe(true);
- const scrollTo = fake.scrollTo;
- scrollTo.mockClear();
- c.contentChanged(); // streaming more content...
- expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down
- expect(c.showButton).toBe(true);
- });
+ const scrollTo = fake.scrollTo;
+ scrollTo.mockClear();
+ c.contentChanged(); // streaming more content...
+ expect(scrollTo).not.toHaveBeenCalled(); // ...must NOT yank the reader down
+ expect(c.showButton).toBe(true);
+ });
- it("self-driven scrolls are not misread as the user scrolling up", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- // contentChanged drives an instant scrollTo, whose synthetic scroll event
- // must NOT flip us to unstuck (selfScrolling guard).
- c.contentChanged();
- expect(c.showButton).toBe(false);
- });
+ it("self-driven scrolls are not misread as the user scrolling up", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ // contentChanged drives an instant scrollTo, whose synthetic scroll event
+ // must NOT flip us to unstuck (selfScrolling guard).
+ c.contentChanged();
+ expect(c.showButton).toBe(false);
+ });
- it("resume re-sticks and smooth-scrolls to the bottom", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- fake.userScrollTo(200);
- expect(c.showButton).toBe(true);
+ it("resume re-sticks and smooth-scrolls to the bottom", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200);
+ expect(c.showButton).toBe(true);
- c.resume();
- expect(fake.scrollTo).toHaveBeenCalledWith({
- top: 1000,
- behavior: "smooth",
- });
- expect(c.showButton).toBe(false);
- });
+ c.resume();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "smooth",
+ });
+ expect(c.showButton).toBe(false);
+ });
- it("reset snaps to the bottom and hides the button", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- c.attach(fake.el);
- fake.userScrollTo(200);
- expect(c.showButton).toBe(true);
- c.reset();
- expect(fake.scrollTo).toHaveBeenCalledWith({
- top: 1000,
- behavior: "instant",
- });
- expect(c.showButton).toBe(false);
- });
+ it("reset snaps to the bottom and hides the button", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ c.attach(fake.el);
+ fake.userScrollTo(200);
+ expect(c.showButton).toBe(true);
+ c.reset();
+ expect(fake.scrollTo).toHaveBeenCalledWith({
+ top: 1000,
+ behavior: "instant",
+ });
+ expect(c.showButton).toBe(false);
+ });
- it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => {
- const holder: { cb: ResizeObserverCallback | null } = { cb: null };
- const observed: unknown[] = [];
- const disconnect = vi.fn();
- class FakeResizeObserver {
- constructor(cb: ResizeObserverCallback) {
- holder.cb = cb;
- }
- observe(target: Element): void {
- observed.push(target);
- }
- unobserve(): void {}
- disconnect = disconnect;
- }
- vi.stubGlobal("ResizeObserver", FakeResizeObserver);
- try {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- const content = { id: "content" } as unknown as HTMLElement;
- const teardown = c.attach(fake.el, content);
+ it("observes content via a ResizeObserver: follows growth while stuck, not while unstuck", () => {
+ const holder: { cb: ResizeObserverCallback | null } = { cb: null };
+ const observed: unknown[] = [];
+ const disconnect = vi.fn();
+ class FakeResizeObserver {
+ constructor(cb: ResizeObserverCallback) {
+ holder.cb = cb;
+ }
+ observe(target: Element): void {
+ observed.push(target);
+ }
+ unobserve(): void {}
+ disconnect = disconnect;
+ }
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ try {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ const content = { id: "content" } as unknown as HTMLElement;
+ const teardown = c.attach(fake.el, content);
- // Observes both the content (it grows) and the scroll container (viewport resize).
- expect(observed).toContain(content);
- expect(observed).toContain(fake.el);
+ // Observes both the content (it grows) and the scroll container (viewport resize).
+ expect(observed).toContain(content);
+ expect(observed).toContain(fake.el);
- // Stuck → a resize keeps us pinned to the bottom.
- fake.scrollTo.mockClear();
- holder.cb?.([], {} as ResizeObserver);
- expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" });
+ // Stuck → a resize keeps us pinned to the bottom.
+ fake.scrollTo.mockClear();
+ holder.cb?.([], {} as ResizeObserver);
+ expect(fake.scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: "instant" });
- // Reader scrolls up → a later resize must NOT yank them down.
- fake.userScrollTo(200);
- fake.scrollTo.mockClear();
- holder.cb?.([], {} as ResizeObserver);
- expect(fake.scrollTo).not.toHaveBeenCalled();
+ // Reader scrolls up → a later resize must NOT yank them down.
+ fake.userScrollTo(200);
+ fake.scrollTo.mockClear();
+ holder.cb?.([], {} as ResizeObserver);
+ expect(fake.scrollTo).not.toHaveBeenCalled();
- // Teardown disconnects the observer.
- teardown();
- expect(disconnect).toHaveBeenCalled();
- } finally {
- vi.unstubAllGlobals();
- }
- });
+ // Teardown disconnects the observer.
+ teardown();
+ expect(disconnect).toHaveBeenCalled();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
- it("attach returns a teardown that removes both listeners", () => {
- const c = createSmartScrollController();
- const fake = createFakeScrollEl();
- const teardown = c.attach(fake.el);
- const before = fake.listenerCount();
- expect(before.get("scroll")?.size).toBe(1);
- expect(before.get("scrollend")?.size).toBe(1);
- teardown();
- expect(before.get("scroll")?.size).toBe(0);
- expect(before.get("scrollend")?.size).toBe(0);
- });
+ it("attach returns a teardown that removes both listeners", () => {
+ const c = createSmartScrollController();
+ const fake = createFakeScrollEl();
+ const teardown = c.attach(fake.el);
+ const before = fake.listenerCount();
+ expect(before.get("scroll")?.size).toBe(1);
+ expect(before.get("scrollend")?.size).toBe(1);
+ teardown();
+ expect(before.get("scroll")?.size).toBe(0);
+ expect(before.get("scrollend")?.size).toBe(0);
+ });
});
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts
index 8f289f1..1e21e3d 100644
--- a/src/features/surface-host/index.ts
+++ b/src/features/surface-host/index.ts
@@ -4,6 +4,6 @@ export { default as SurfaceView } from "./ui/SurfaceView.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "surface-host",
- description: "Generic renderer for backend-declared surfaces",
+ name: "surface-host",
+ description: "Generic renderer for backend-declared surfaces",
} as const;
diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts
index ce078d9..8d55eb7 100644
--- a/src/features/surface-host/logic/message-queue.test.ts
+++ b/src/features/surface-host/logic/message-queue.test.ts
@@ -3,46 +3,46 @@ import { describe, expect, it } from "vitest";
import { parseMessageQueuePayload } from "./message-queue";
const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({
- id,
- text,
- queuedAt,
+ id,
+ text,
+ queuedAt,
});
describe("parseMessageQueuePayload", () => {
- it("parses a well-formed payload with messages", () => {
- const data = parseMessageQueuePayload({
- messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
- });
- expect(data).toEqual({
- messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
- });
- });
+ it("parses a well-formed payload with messages", () => {
+ const data = parseMessageQueuePayload({
+ messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
+ });
+ expect(data).toEqual({
+ messages: [msg("m1", "steer left"), msg("m2", "actually, go right")],
+ });
+ });
- it("parses an empty-messages payload (queue is empty)", () => {
- expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] });
- });
+ it("parses an empty-messages payload (queue is empty)", () => {
+ expect(parseMessageQueuePayload({ messages: [] })).toEqual({ messages: [] });
+ });
- it("preserves message order", () => {
- const data = parseMessageQueuePayload({
- messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")],
- });
- expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]);
- });
+ it("preserves message order", () => {
+ const data = parseMessageQueuePayload({
+ messages: [msg("a", "first"), msg("b", "second"), msg("c", "third")],
+ });
+ expect(data?.messages.map((m) => m.id)).toEqual(["a", "b", "c"]);
+ });
- it.each([
- ["null", null],
- ["a number", 7],
- ["a string", "nope"],
- ["missing messages key", { foo: [] }],
- ["messages not an array", { messages: "x" }],
- ["entry not an object", { messages: ["x"] }],
- ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }],
- ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }],
- ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }],
- ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }],
- ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }],
- ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }],
- ])("returns null for invalid payload: %s", (_label, payload) => {
- expect(parseMessageQueuePayload(payload)).toBeNull();
- });
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing messages key", { foo: [] }],
+ ["messages not an array", { messages: "x" }],
+ ["entry not an object", { messages: ["x"] }],
+ ["entry missing id", { messages: [{ text: "x", queuedAt: 1 }] }],
+ ["entry with non-string id", { messages: [{ id: 1, text: "x", queuedAt: 1 }] }],
+ ["entry missing text", { messages: [{ id: "m1", queuedAt: 1 }] }],
+ ["entry with non-string text", { messages: [{ id: "m1", text: 1, queuedAt: 1 }] }],
+ ["entry missing queuedAt", { messages: [{ id: "m1", text: "x" }] }],
+ ["entry with non-finite queuedAt", { messages: [msg("m1", "x", Number.NaN)] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseMessageQueuePayload(payload)).toBeNull();
+ });
});
diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts
index a8e1567..79707a5 100644
--- a/src/features/surface-host/logic/message-queue.ts
+++ b/src/features/surface-host/logic/message-queue.ts
@@ -14,31 +14,31 @@ import type { QueuedMessage } from "@dispatch/wire";
* payload shape.
*/
export interface MessageQueueData {
- readonly messages: readonly QueuedMessage[];
+ readonly messages: readonly QueuedMessage[];
}
function isQueuedMessage(v: unknown): v is QueuedMessage {
- if (typeof v !== "object" || v === null) return false;
- const o = v as Record<string, unknown>;
- return (
- typeof o.id === "string" &&
- typeof o.text === "string" &&
- typeof o.queuedAt === "number" &&
- Number.isFinite(o.queuedAt)
- );
+ if (typeof v !== "object" || v === null) return false;
+ const o = v as Record<string, unknown>;
+ return (
+ typeof o.id === "string" &&
+ typeof o.text === "string" &&
+ typeof o.queuedAt === "number" &&
+ Number.isFinite(o.queuedAt)
+ );
}
export function parseMessageQueuePayload(payload: unknown): MessageQueueData | null {
- if (typeof payload !== "object" || payload === null) return null;
- const obj = payload as Record<string, unknown>;
- const raw = obj.messages;
- if (!Array.isArray(raw)) return null;
- const messages: QueuedMessage[] = [];
- for (const entry of raw) {
- if (!isQueuedMessage(entry)) return null;
- messages.push(entry);
- }
- return { messages };
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+ const raw = obj.messages;
+ if (!Array.isArray(raw)) return null;
+ const messages: QueuedMessage[] = [];
+ for (const entry of raw) {
+ if (!isQueuedMessage(entry)) return null;
+ messages.push(entry);
+ }
+ return { messages };
}
/** The `rendererId` the message-queue extension's `custom` surface field uses. */
diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts
index be296a7..9c8a34d 100644
--- a/src/features/surface-host/logic/plan.test.ts
+++ b/src/features/surface-host/logic/plan.test.ts
@@ -4,247 +4,247 @@ import { buildInvoke, groupRenderFields, planSurface } from "./plan";
import type { FieldView } from "./types";
const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({
- id: "test-surface",
- region: "test",
- title: "Test Surface",
- fields,
+ id: "test-surface",
+ region: "test",
+ title: "Test Surface",
+ fields,
});
describe("planSurface", () => {
- it("maps a toggle field to a ToggleFieldView", () => {
- const plan = planSurface(
- makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }),
- );
- expect(plan.fields).toEqual([
- { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } },
- ]);
- });
-
- it("maps a progress field to a ProgressFieldView", () => {
- const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 }));
- expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]);
- });
-
- it("maps a selector field to a SelectorFieldView", () => {
- const plan = planSurface(
- makeSpec({
- kind: "selector",
- label: "Model",
- value: "gpt-4",
- options: [
- { value: "gpt-4", label: "GPT-4" },
- { value: "gpt-3.5", label: "GPT-3.5" },
- ],
- action: { actionId: "set-model" },
- }),
- );
- expect(plan.fields).toEqual([
- {
- kind: "selector",
- label: "Model",
- value: "gpt-4",
- options: [
- { value: "gpt-4", label: "GPT-4" },
- { value: "gpt-3.5", label: "GPT-3.5" },
- ],
- action: { actionId: "set-model" },
- },
- ]);
- });
-
- it("maps a stat field to a StatFieldView", () => {
- const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" }));
- expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]);
- });
-
- it("maps a number field to a NumberFieldView, carrying optional hints", () => {
- const plan = planSurface(
- makeSpec({
- kind: "number",
- label: "Interval",
- value: 240,
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- }),
- );
- expect(plan.fields).toEqual([
- {
- kind: "number",
- label: "Interval",
- value: 240,
- min: 1,
- step: 1,
- unit: "s",
- action: { actionId: "cache-warming/set-interval" },
- },
- ]);
- });
-
- it("omits absent number hints (no max key when undefined)", () => {
- const plan = planSurface(
- makeSpec({
- kind: "number",
- label: "Interval",
- value: 240,
- min: 1,
- action: { actionId: "set" },
- }),
- );
- const field = plan.fields[0];
- expect(field).not.toHaveProperty("max");
- expect(field).not.toHaveProperty("step");
- expect(field).not.toHaveProperty("unit");
- });
-
- it("maps a button field to a ButtonFieldView", () => {
- const plan = planSurface(
- makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }),
- );
- expect(plan.fields).toEqual([
- { kind: "button", label: "Retry", action: { actionId: "retry" } },
- ]);
- });
-
- it("preserves field order", () => {
- const plan = planSurface(
- makeSpec(
- { kind: "stat", label: "A", value: "1" },
- { kind: "toggle", label: "B", value: false, action: { actionId: "b" } },
- { kind: "progress", label: "C", value: 0.5 },
- { kind: "button", label: "D", action: { actionId: "d" } },
- ),
- );
- expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]);
- });
-
- it("drops unknown field kinds gracefully", () => {
- const plan = planSurface(
- makeSpec({ kind: "stat", label: "Known", value: "ok" }, {
- kind: "future-kind" as "stat",
- label: "Unknown",
- value: "?",
- } as SurfaceField),
- );
- expect(plan.fields).toHaveLength(1);
- const first = plan.fields[0];
- expect(first && "label" in first ? first.label : null).toBe("Known");
- });
-
- it("carries custom fields through verbatim, preserving order", () => {
- const plan = planSurface(
- makeSpec(
- { kind: "stat", label: "Before", value: "1" },
- { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } },
- { kind: "stat", label: "After", value: "2" },
- ),
- );
- expect(plan.fields).toHaveLength(3);
- expect(plan.fields[1]).toEqual({
- kind: "custom",
- rendererId: "chart",
- payload: { data: [1, 2, 3] },
- });
- });
-
- it("returns empty fields for an empty spec", () => {
- const plan = planSurface(makeSpec());
- expect(plan.fields).toEqual([]);
- });
-
- it("keeps every custom field (render-time decides whether to show each)", () => {
- const plan = planSurface(
- makeSpec(
- { kind: "custom", rendererId: "x", payload: null },
- { kind: "custom", rendererId: "y", payload: 42 },
- ),
- );
- expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]);
- });
+ it("maps a toggle field to a ToggleFieldView", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } }),
+ );
+ expect(plan.fields).toEqual([
+ { kind: "toggle", label: "Dark mode", value: true, action: { actionId: "dm" } },
+ ]);
+ });
+
+ it("maps a progress field to a ProgressFieldView", () => {
+ const plan = planSurface(makeSpec({ kind: "progress", label: "Loading", value: 0.42 }));
+ expect(plan.fields).toEqual([{ kind: "progress", label: "Loading", value: 0.42 }]);
+ });
+
+ it("maps a selector field to a SelectorFieldView", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "selector",
+ label: "Model",
+ value: "gpt-4",
+ options: [
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5", label: "GPT-3.5" },
+ ],
+ action: { actionId: "set-model" },
+ }),
+ );
+ expect(plan.fields).toEqual([
+ {
+ kind: "selector",
+ label: "Model",
+ value: "gpt-4",
+ options: [
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5", label: "GPT-3.5" },
+ ],
+ action: { actionId: "set-model" },
+ },
+ ]);
+ });
+
+ it("maps a stat field to a StatFieldView", () => {
+ const plan = planSurface(makeSpec({ kind: "stat", label: "Tokens", value: "1,234" }));
+ expect(plan.fields).toEqual([{ kind: "stat", label: "Tokens", value: "1,234" }]);
+ });
+
+ it("maps a number field to a NumberFieldView, carrying optional hints", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ }),
+ );
+ expect(plan.fields).toEqual([
+ {
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ min: 1,
+ step: 1,
+ unit: "s",
+ action: { actionId: "cache-warming/set-interval" },
+ },
+ ]);
+ });
+
+ it("omits absent number hints (no max key when undefined)", () => {
+ const plan = planSurface(
+ makeSpec({
+ kind: "number",
+ label: "Interval",
+ value: 240,
+ min: 1,
+ action: { actionId: "set" },
+ }),
+ );
+ const field = plan.fields[0];
+ expect(field).not.toHaveProperty("max");
+ expect(field).not.toHaveProperty("step");
+ expect(field).not.toHaveProperty("unit");
+ });
+
+ it("maps a button field to a ButtonFieldView", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "button", label: "Retry", action: { actionId: "retry" } }),
+ );
+ expect(plan.fields).toEqual([
+ { kind: "button", label: "Retry", action: { actionId: "retry" } },
+ ]);
+ });
+
+ it("preserves field order", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "stat", label: "A", value: "1" },
+ { kind: "toggle", label: "B", value: false, action: { actionId: "b" } },
+ { kind: "progress", label: "C", value: 0.5 },
+ { kind: "button", label: "D", action: { actionId: "d" } },
+ ),
+ );
+ expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]);
+ });
+
+ it("drops unknown field kinds gracefully", () => {
+ const plan = planSurface(
+ makeSpec({ kind: "stat", label: "Known", value: "ok" }, {
+ kind: "future-kind" as "stat",
+ label: "Unknown",
+ value: "?",
+ } as SurfaceField),
+ );
+ expect(plan.fields).toHaveLength(1);
+ const first = plan.fields[0];
+ expect(first && "label" in first ? first.label : null).toBe("Known");
+ });
+
+ it("carries custom fields through verbatim, preserving order", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "stat", label: "Before", value: "1" },
+ { kind: "custom", rendererId: "chart", payload: { data: [1, 2, 3] } },
+ { kind: "stat", label: "After", value: "2" },
+ ),
+ );
+ expect(plan.fields).toHaveLength(3);
+ expect(plan.fields[1]).toEqual({
+ kind: "custom",
+ rendererId: "chart",
+ payload: { data: [1, 2, 3] },
+ });
+ });
+
+ it("returns empty fields for an empty spec", () => {
+ const plan = planSurface(makeSpec());
+ expect(plan.fields).toEqual([]);
+ });
+
+ it("keeps every custom field (render-time decides whether to show each)", () => {
+ const plan = planSurface(
+ makeSpec(
+ { kind: "custom", rendererId: "x", payload: null },
+ { kind: "custom", rendererId: "y", payload: 42 },
+ ),
+ );
+ expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]);
+ });
});
describe("groupRenderFields", () => {
- const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value });
- const toggle = (label: string): FieldView => ({
- kind: "toggle",
- label,
- value: false,
- action: { actionId: label },
- });
-
- it("coalesces consecutive stats into a single stats group", () => {
- const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]);
- expect(groups).toHaveLength(1);
- expect(groups[0]).toEqual({
- type: "stats",
- stats: [
- { kind: "stat", label: "a", value: "1" },
- { kind: "stat", label: "b", value: "2" },
- { kind: "stat", label: "c", value: "3" },
- ],
- });
- });
-
- it("keeps non-stat fields as standalone groups and preserves order", () => {
- const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]);
- expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
- const first = groups[0];
- const last = groups[2];
- if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping");
- expect(first.stats.map((s) => s.label)).toEqual(["a"]);
- expect(last.stats.map((s) => s.label)).toEqual(["b"]);
- });
-
- it("starts a new stats run after an interrupting field", () => {
- const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]);
- expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
- });
-
- it("returns no groups for an empty field list", () => {
- expect(groupRenderFields([])).toEqual([]);
- });
+ const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value });
+ const toggle = (label: string): FieldView => ({
+ kind: "toggle",
+ label,
+ value: false,
+ action: { actionId: label },
+ });
+
+ it("coalesces consecutive stats into a single stats group", () => {
+ const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]);
+ expect(groups).toHaveLength(1);
+ expect(groups[0]).toEqual({
+ type: "stats",
+ stats: [
+ { kind: "stat", label: "a", value: "1" },
+ { kind: "stat", label: "b", value: "2" },
+ { kind: "stat", label: "c", value: "3" },
+ ],
+ });
+ });
+
+ it("keeps non-stat fields as standalone groups and preserves order", () => {
+ const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]);
+ expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
+ const first = groups[0];
+ const last = groups[2];
+ if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping");
+ expect(first.stats.map((s) => s.label)).toEqual(["a"]);
+ expect(last.stats.map((s) => s.label)).toEqual(["b"]);
+ });
+
+ it("starts a new stats run after an interrupting field", () => {
+ const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]);
+ expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]);
+ });
+
+ it("returns no groups for an empty field list", () => {
+ expect(groupRenderFields([])).toEqual([]);
+ });
});
describe("buildInvoke", () => {
- it("builds an invoke message for a toggle field", () => {
- const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } };
- const msg = buildInvoke("s1", field, true);
- expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true });
- });
-
- it("builds an invoke message for a selector field", () => {
- const field = {
- kind: "selector" as const,
- label: "S",
- value: "a",
- options: [],
- action: { actionId: "sel" },
- };
- const msg = buildInvoke("s1", field, "b");
- expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" });
- });
-
- it("builds an invoke message without payload for a button field", () => {
- const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
- const msg = buildInvoke("s1", field);
- expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" });
- });
-
- it("omits payload key when value is undefined", () => {
- const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
- const msg = buildInvoke("s1", field, undefined);
- expect(msg).not.toHaveProperty("payload");
- });
-
- it("uses the field's actionId, not a surface-level id", () => {
- const field = {
- kind: "toggle" as const,
- label: "X",
- value: true,
- action: { actionId: "custom-action-123" },
- };
- const msg = buildInvoke("surf", field, false);
- expect(msg.actionId).toBe("custom-action-123");
- });
+ it("builds an invoke message for a toggle field", () => {
+ const field = { kind: "toggle" as const, label: "T", value: false, action: { actionId: "t" } };
+ const msg = buildInvoke("s1", field, true);
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "t", payload: true });
+ });
+
+ it("builds an invoke message for a selector field", () => {
+ const field = {
+ kind: "selector" as const,
+ label: "S",
+ value: "a",
+ options: [],
+ action: { actionId: "sel" },
+ };
+ const msg = buildInvoke("s1", field, "b");
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "sel", payload: "b" });
+ });
+
+ it("builds an invoke message without payload for a button field", () => {
+ const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
+ const msg = buildInvoke("s1", field);
+ expect(msg).toEqual({ type: "invoke", surfaceId: "s1", actionId: "btn" });
+ });
+
+ it("omits payload key when value is undefined", () => {
+ const field = { kind: "button" as const, label: "B", action: { actionId: "btn" } };
+ const msg = buildInvoke("s1", field, undefined);
+ expect(msg).not.toHaveProperty("payload");
+ });
+
+ it("uses the field's actionId, not a surface-level id", () => {
+ const field = {
+ kind: "toggle" as const,
+ label: "X",
+ value: true,
+ action: { actionId: "custom-action-123" },
+ };
+ const msg = buildInvoke("surf", field, false);
+ expect(msg.actionId).toBe("custom-action-123");
+ });
});
diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts
index 89088c3..c8f82b9 100644
--- a/src/features/surface-host/logic/plan.ts
+++ b/src/features/surface-host/logic/plan.ts
@@ -1,20 +1,20 @@
import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
import type {
- FieldView,
- NumberFieldView,
- RenderGroup,
- StatFieldView,
- SurfaceRenderPlan,
+ FieldView,
+ NumberFieldView,
+ RenderGroup,
+ StatFieldView,
+ SurfaceRenderPlan,
} from "./types";
const KNOWN_KINDS = new Set([
- "toggle",
- "progress",
- "selector",
- "stat",
- "number",
- "button",
- "custom",
+ "toggle",
+ "progress",
+ "selector",
+ "stat",
+ "number",
+ "button",
+ "custom",
]);
/**
@@ -25,73 +25,73 @@ const KNOWN_KINDS = new Set([
* decision (unknown `rendererId` → skipped there), not a planning one.
*/
export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
- const fields: FieldView[] = [];
- for (const field of spec.fields) {
- if (!KNOWN_KINDS.has(field.kind)) continue;
- switch (field.kind) {
- case "toggle":
- fields.push({
- kind: "toggle",
- label: field.label,
- value: field.value,
- action: field.action,
- });
- break;
- case "progress":
- fields.push({
- kind: "progress",
- label: field.label,
- value: field.value,
- });
- break;
- case "selector":
- fields.push({
- kind: "selector",
- label: field.label,
- value: field.value,
- options: field.options,
- action: field.action,
- });
- break;
- case "stat":
- fields.push({
- kind: "stat",
- label: field.label,
- value: field.value,
- });
- break;
- case "number": {
- // Carry optional hints only when present (exactOptionalPropertyTypes).
- const view: NumberFieldView = {
- kind: "number",
- label: field.label,
- value: field.value,
- action: field.action,
- ...(field.min !== undefined ? { min: field.min } : {}),
- ...(field.max !== undefined ? { max: field.max } : {}),
- ...(field.step !== undefined ? { step: field.step } : {}),
- ...(field.unit !== undefined ? { unit: field.unit } : {}),
- };
- fields.push(view);
- break;
- }
- case "button":
- fields.push({
- kind: "button",
- label: field.label,
- action: field.action,
- });
- break;
- case "custom":
- fields.push({
- kind: "custom",
- rendererId: field.rendererId,
- payload: field.payload,
- });
- break;
- }
- }
- return { fields };
+ const fields: FieldView[] = [];
+ for (const field of spec.fields) {
+ if (!KNOWN_KINDS.has(field.kind)) continue;
+ switch (field.kind) {
+ case "toggle":
+ fields.push({
+ kind: "toggle",
+ label: field.label,
+ value: field.value,
+ action: field.action,
+ });
+ break;
+ case "progress":
+ fields.push({
+ kind: "progress",
+ label: field.label,
+ value: field.value,
+ });
+ break;
+ case "selector":
+ fields.push({
+ kind: "selector",
+ label: field.label,
+ value: field.value,
+ options: field.options,
+ action: field.action,
+ });
+ break;
+ case "stat":
+ fields.push({
+ kind: "stat",
+ label: field.label,
+ value: field.value,
+ });
+ break;
+ case "number": {
+ // Carry optional hints only when present (exactOptionalPropertyTypes).
+ const view: NumberFieldView = {
+ kind: "number",
+ label: field.label,
+ value: field.value,
+ action: field.action,
+ ...(field.min !== undefined ? { min: field.min } : {}),
+ ...(field.max !== undefined ? { max: field.max } : {}),
+ ...(field.step !== undefined ? { step: field.step } : {}),
+ ...(field.unit !== undefined ? { unit: field.unit } : {}),
+ };
+ fields.push(view);
+ break;
+ }
+ case "button":
+ fields.push({
+ kind: "button",
+ label: field.label,
+ action: field.action,
+ });
+ break;
+ case "custom":
+ fields.push({
+ kind: "custom",
+ rendererId: field.rendererId,
+ payload: field.payload,
+ });
+ break;
+ }
+ }
+ return { fields };
}
/**
@@ -100,24 +100,24 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan {
* other field stays a standalone `field` group. Order is preserved. Pure.
*/
export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] {
- const groups: RenderGroup[] = [];
- let run: StatFieldView[] = [];
- const flush = (): void => {
- if (run.length > 0) {
- groups.push({ type: "stats", stats: run });
- run = [];
- }
- };
- for (const field of fields) {
- if (field.kind === "stat") {
- run.push(field);
- } else {
- flush();
- groups.push({ type: "field", field });
- }
- }
- flush();
- return groups;
+ const groups: RenderGroup[] = [];
+ let run: StatFieldView[] = [];
+ const flush = (): void => {
+ if (run.length > 0) {
+ groups.push({ type: "stats", stats: run });
+ run = [];
+ }
+ };
+ for (const field of fields) {
+ if (field.kind === "stat") {
+ run.push(field);
+ } else {
+ flush();
+ groups.push({ type: "field", field });
+ }
+ }
+ flush();
+ return groups;
}
/**
@@ -126,13 +126,13 @@ export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] {
* for button the payload is omitted.
*/
export function buildInvoke(
- surfaceId: string,
- field: Extract<FieldView, { action: unknown }>,
- value?: unknown,
+ surfaceId: string,
+ field: Extract<FieldView, { action: unknown }>,
+ value?: unknown,
): InvokeMessage {
- const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId };
- if (value !== undefined) {
- return { ...base, payload: value };
- }
- return base;
+ const base = { type: "invoke" as const, surfaceId, actionId: field.action.actionId };
+ if (value !== undefined) {
+ return { ...base, payload: value };
+ }
+ return base;
}
diff --git a/src/features/surface-host/logic/table.test.ts b/src/features/surface-host/logic/table.test.ts
index e55b3f7..6fb558a 100644
--- a/src/features/surface-host/logic/table.test.ts
+++ b/src/features/surface-host/logic/table.test.ts
@@ -2,46 +2,46 @@ import { describe, expect, it } from "vitest";
import { parseTablePayload } from "./table";
describe("parseTablePayload", () => {
- it("parses a well-formed table payload", () => {
- const data = parseTablePayload({
- columns: ["Name", "Version"],
- rows: [
- ["alpha", "1.0"],
- ["beta", "2.3"],
- ],
- });
- expect(data).toEqual({
- columns: ["Name", "Version"],
- rows: [
- ["alpha", "1.0"],
- ["beta", "2.3"],
- ],
- });
- });
+ it("parses a well-formed table payload", () => {
+ const data = parseTablePayload({
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ });
+ expect(data).toEqual({
+ columns: ["Name", "Version"],
+ rows: [
+ ["alpha", "1.0"],
+ ["beta", "2.3"],
+ ],
+ });
+ });
- it("coerces numeric and boolean cells to strings", () => {
- const data = parseTablePayload({
- columns: ["k", "n", "b"],
- rows: [["x", 42, true]],
- });
- expect(data?.rows[0]).toEqual(["x", "42", "true"]);
- });
+ it("coerces numeric and boolean cells to strings", () => {
+ const data = parseTablePayload({
+ columns: ["k", "n", "b"],
+ rows: [["x", 42, true]],
+ });
+ expect(data?.rows[0]).toEqual(["x", "42", "true"]);
+ });
- it("accepts an empty rows array", () => {
- expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] });
- });
+ it("accepts an empty rows array", () => {
+ expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] });
+ });
- it.each([
- ["null", null],
- ["a number", 7],
- ["a string", "nope"],
- ["missing columns", { rows: [] }],
- ["missing rows", { columns: ["A"] }],
- ["non-string column", { columns: [1], rows: [] }],
- ["row that is not an array", { columns: ["A"], rows: ["x"] }],
- ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }],
- ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }],
- ])("returns null for invalid payload: %s", (_label, payload) => {
- expect(parseTablePayload(payload)).toBeNull();
- });
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing columns", { rows: [] }],
+ ["missing rows", { columns: ["A"] }],
+ ["non-string column", { columns: [1], rows: [] }],
+ ["row that is not an array", { columns: ["A"], rows: ["x"] }],
+ ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }],
+ ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseTablePayload(payload)).toBeNull();
+ });
});
diff --git a/src/features/surface-host/logic/table.ts b/src/features/surface-host/logic/table.ts
index 027553c..5d2b831 100644
--- a/src/features/surface-host/logic/table.ts
+++ b/src/features/surface-host/logic/table.ts
@@ -9,46 +9,46 @@
*/
export interface TableData {
- readonly columns: readonly string[];
- readonly rows: readonly (readonly string[])[];
+ readonly columns: readonly string[];
+ readonly rows: readonly (readonly string[])[];
}
function isStringArray(v: unknown): v is unknown[] {
- return Array.isArray(v);
+ return Array.isArray(v);
}
function coerceCell(v: unknown): string | null {
- if (typeof v === "string") return v;
- if (typeof v === "number" && Number.isFinite(v)) return String(v);
- if (typeof v === "boolean") return String(v);
- return null;
+ if (typeof v === "string") return v;
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
+ if (typeof v === "boolean") return String(v);
+ return null;
}
export function parseTablePayload(payload: unknown): TableData | null {
- if (typeof payload !== "object" || payload === null) return null;
- const obj = payload as Record<string, unknown>;
-
- const rawColumns = obj.columns;
- const rawRows = obj.rows;
- if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null;
-
- const columns: string[] = [];
- for (const col of rawColumns) {
- if (typeof col !== "string") return null;
- columns.push(col);
- }
-
- const rows: string[][] = [];
- for (const row of rawRows) {
- if (!Array.isArray(row)) return null;
- const cells: string[] = [];
- for (const cell of row) {
- const c = coerceCell(cell);
- if (c === null) return null;
- cells.push(c);
- }
- rows.push(cells);
- }
-
- return { columns, rows };
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+
+ const rawColumns = obj.columns;
+ const rawRows = obj.rows;
+ if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null;
+
+ const columns: string[] = [];
+ for (const col of rawColumns) {
+ if (typeof col !== "string") return null;
+ columns.push(col);
+ }
+
+ const rows: string[][] = [];
+ for (const row of rawRows) {
+ if (!Array.isArray(row)) return null;
+ const cells: string[] = [];
+ for (const cell of row) {
+ const c = coerceCell(cell);
+ if (c === null) return null;
+ cells.push(c);
+ }
+ rows.push(cells);
+ }
+
+ return { columns, rows };
}
diff --git a/src/features/surface-host/logic/todo.test.ts b/src/features/surface-host/logic/todo.test.ts
index 225ecde..66ff036 100644
--- a/src/features/surface-host/logic/todo.test.ts
+++ b/src/features/surface-host/logic/todo.test.ts
@@ -2,66 +2,66 @@ import { describe, expect, it } from "vitest";
import { parseTodoPayload, type TodoItem } from "./todo";
const item = (content: string, status: TodoItem["status"] = "pending"): TodoItem => ({
- content,
- status,
+ content,
+ status,
});
describe("parseTodoPayload", () => {
- it("parses a well-formed payload with items", () => {
- const data = parseTodoPayload({
- todos: [
- item("Write tests", "in_progress"),
- item("Ship it", "pending"),
- item("Read docs", "completed"),
- ],
- });
- expect(data).toEqual({
- todos: [
- item("Write tests", "in_progress"),
- item("Ship it", "pending"),
- item("Read docs", "completed"),
- ],
- });
- });
+ it("parses a well-formed payload with items", () => {
+ const data = parseTodoPayload({
+ todos: [
+ item("Write tests", "in_progress"),
+ item("Ship it", "pending"),
+ item("Read docs", "completed"),
+ ],
+ });
+ expect(data).toEqual({
+ todos: [
+ item("Write tests", "in_progress"),
+ item("Ship it", "pending"),
+ item("Read docs", "completed"),
+ ],
+ });
+ });
- it("parses an empty-todos payload", () => {
- expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] });
- });
+ it("parses an empty-todos payload", () => {
+ expect(parseTodoPayload({ todos: [] })).toEqual({ todos: [] });
+ });
- it("preserves item order", () => {
- const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] });
- expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]);
- });
+ it("preserves item order", () => {
+ const data = parseTodoPayload({ todos: [item("a"), item("b"), item("c")] });
+ expect(data?.todos.map((t) => t.content)).toEqual(["a", "b", "c"]);
+ });
- it("accepts all four status values", () => {
- const data = parseTodoPayload({
- todos: [
- item("p", "pending"),
- item("i", "in_progress"),
- item("c", "completed"),
- item("x", "cancelled"),
- ],
- });
- expect(data?.todos.map((t) => t.status)).toEqual([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
- ]);
- });
+ it("accepts all four status values", () => {
+ const data = parseTodoPayload({
+ todos: [
+ item("p", "pending"),
+ item("i", "in_progress"),
+ item("c", "completed"),
+ item("x", "cancelled"),
+ ],
+ });
+ expect(data?.todos.map((t) => t.status)).toEqual([
+ "pending",
+ "in_progress",
+ "completed",
+ "cancelled",
+ ]);
+ });
- it.each([
- ["null", null],
- ["a number", 7],
- ["a string", "nope"],
- ["missing todos key", { foo: [] }],
- ["todos not an array", { todos: "x" }],
- ["entry not an object", { todos: ["x"] }],
- ["entry missing content", { todos: [{ status: "pending" }] }],
- ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }],
- ["entry missing status", { todos: [{ content: "x" }] }],
- ["entry with invalid status", { todos: [item("x", "done" as never)] }],
- ])("returns null for invalid payload: %s", (_label, payload) => {
- expect(parseTodoPayload(payload)).toBeNull();
- });
+ it.each([
+ ["null", null],
+ ["a number", 7],
+ ["a string", "nope"],
+ ["missing todos key", { foo: [] }],
+ ["todos not an array", { todos: "x" }],
+ ["entry not an object", { todos: ["x"] }],
+ ["entry missing content", { todos: [{ status: "pending" }] }],
+ ["entry with non-string content", { todos: [{ content: 1, status: "pending" }] }],
+ ["entry missing status", { todos: [{ content: "x" }] }],
+ ["entry with invalid status", { todos: [item("x", "done" as never)] }],
+ ])("returns null for invalid payload: %s", (_label, payload) => {
+ expect(parseTodoPayload(payload)).toBeNull();
+ });
});
diff --git a/src/features/surface-host/logic/todo.ts b/src/features/surface-host/logic/todo.ts
index e442e78..8b8a5ef 100644
--- a/src/features/surface-host/logic/todo.ts
+++ b/src/features/surface-host/logic/todo.ts
@@ -16,33 +16,33 @@
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
export interface TodoItem {
- readonly content: string;
- readonly status: TodoStatus;
+ readonly content: string;
+ readonly status: TodoStatus;
}
export interface TodoData {
- readonly todos: readonly TodoItem[];
+ readonly todos: readonly TodoItem[];
}
const STATUSES = new Set<string>(["pending", "in_progress", "completed", "cancelled"]);
function isTodoItem(v: unknown): v is TodoItem {
- if (typeof v !== "object" || v === null) return false;
- const o = v as Record<string, unknown>;
- return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status);
+ if (typeof v !== "object" || v === null) return false;
+ const o = v as Record<string, unknown>;
+ return typeof o.content === "string" && typeof o.status === "string" && STATUSES.has(o.status);
}
export function parseTodoPayload(payload: unknown): TodoData | null {
- if (typeof payload !== "object" || payload === null) return null;
- const obj = payload as Record<string, unknown>;
- const raw = obj.todos;
- if (!Array.isArray(raw)) return null;
- const todos: TodoItem[] = [];
- for (const entry of raw) {
- if (!isTodoItem(entry)) return null;
- todos.push(entry);
- }
- return { todos };
+ if (typeof payload !== "object" || payload === null) return null;
+ const obj = payload as Record<string, unknown>;
+ const raw = obj.todos;
+ if (!Array.isArray(raw)) return null;
+ const todos: TodoItem[] = [];
+ for (const entry of raw) {
+ if (!isTodoItem(entry)) return null;
+ todos.push(entry);
+ }
+ return { todos };
}
/** The `rendererId` the `todo` extension's `custom` surface field uses. */
diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts
index 23f8757..11c222f 100644
--- a/src/features/surface-host/logic/types.ts
+++ b/src/features/surface-host/logic/types.ts
@@ -2,33 +2,33 @@ import type { ActionRef, SurfaceOption } from "@dispatch/ui-contract";
/** Normalised view-model for a toggle field. */
export interface ToggleFieldView {
- readonly kind: "toggle";
- readonly label: string;
- readonly value: boolean;
- readonly action: ActionRef;
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
}
/** Normalised view-model for a progress field. */
export interface ProgressFieldView {
- readonly kind: "progress";
- readonly label: string;
- readonly value: number;
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
}
/** Normalised view-model for a selector field. */
export interface SelectorFieldView {
- readonly kind: "selector";
- readonly label: string;
- readonly value: string;
- readonly options: readonly SurfaceOption[];
- readonly action: ActionRef;
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
}
/** Normalised view-model for a stat field. */
export interface StatFieldView {
- readonly kind: "stat";
- readonly label: string;
- readonly value: string;
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
}
/**
@@ -37,21 +37,21 @@ export interface StatFieldView {
* the spec omits them). The renderer posts the new number as the action payload.
*/
export interface NumberFieldView {
- readonly kind: "number";
- readonly label: string;
- readonly value: number;
- readonly min?: number;
- readonly max?: number;
- readonly step?: number;
- readonly unit?: string;
- readonly action: ActionRef;
+ readonly kind: "number";
+ readonly label: string;
+ readonly value: number;
+ readonly min?: number;
+ readonly max?: number;
+ readonly step?: number;
+ readonly unit?: string;
+ readonly action: ActionRef;
}
/** Normalised view-model for a button field. */
export interface ButtonFieldView {
- readonly kind: "button";
- readonly label: string;
- readonly action: ActionRef;
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
}
/**
@@ -60,24 +60,24 @@ export interface ButtonFieldView {
* never a surface id) and gracefully skips ids it has no renderer for.
*/
export interface CustomFieldView {
- readonly kind: "custom";
- readonly rendererId: string;
- readonly payload: unknown;
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
}
/** A normalised field view-model — one entry per renderable field kind. */
export type FieldView =
- | ToggleFieldView
- | ProgressFieldView
- | SelectorFieldView
- | StatFieldView
- | NumberFieldView
- | ButtonFieldView
- | CustomFieldView;
+ | ToggleFieldView
+ | ProgressFieldView
+ | SelectorFieldView
+ | StatFieldView
+ | NumberFieldView
+ | ButtonFieldView
+ | CustomFieldView;
/** The output of `planSurface`: the ordered list of renderable fields. */
export interface SurfaceRenderPlan {
- readonly fields: readonly FieldView[];
+ readonly fields: readonly FieldView[];
}
/**
@@ -86,5 +86,5 @@ export interface SurfaceRenderPlan {
* GENERIC presentation rule keyed on field kind — it never inspects a surface id.
*/
export type RenderGroup =
- | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
- | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };
+ | { readonly type: "stats"; readonly stats: readonly StatFieldView[] }
+ | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> };
diff --git a/src/features/surface-host/ui/Button.svelte b/src/features/surface-host/ui/Button.svelte
index 62d7acf..ee9097c 100644
--- a/src/features/surface-host/ui/Button.svelte
+++ b/src/features/surface-host/ui/Button.svelte
@@ -1,21 +1,21 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { ButtonFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { ButtonFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ButtonFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleClick() {
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- });
- }
+ function handleClick() {
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ });
+ }
</script>
<button onclick={handleClick}>{field.label}</button>
diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte
index 12de970..554fa02 100644
--- a/src/features/surface-host/ui/MessageQueueList.svelte
+++ b/src/features/surface-host/ui/MessageQueueList.svelte
@@ -1,22 +1,22 @@
<script lang="ts">
- import { parseMessageQueuePayload } from "../logic/message-queue";
+ import { parseMessageQueuePayload } from "../logic/message-queue";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- // Parse defensively; an unparseable payload yields null → render nothing
- // (graceful skip, per the custom-field contract).
- const data = $derived(parseMessageQueuePayload(payload));
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseMessageQueuePayload(payload));
</script>
{#if data !== null && data.messages.length > 0}
- <ul class="flex flex-col gap-1 text-sm">
- {#each data.messages as msg (msg.id)}
- <li class="rounded-box bg-base-200 px-3 py-2">
- <p class="whitespace-pre-wrap">{msg.text}</p>
- <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
- {new Date(msg.queuedAt).toLocaleTimeString()}
- </time>
- </li>
- {/each}
- </ul>
+ <ul class="flex flex-col gap-1 text-sm">
+ {#each data.messages as msg (msg.id)}
+ <li class="rounded-box bg-base-200 px-3 py-2">
+ <p class="whitespace-pre-wrap">{msg.text}</p>
+ <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}>
+ {new Date(msg.queuedAt).toLocaleTimeString()}
+ </time>
+ </li>
+ {/each}
+ </ul>
{/if}
diff --git a/src/features/surface-host/ui/Number.svelte b/src/features/surface-host/ui/Number.svelte
index 0f3323d..5a67087 100644
--- a/src/features/surface-host/ui/Number.svelte
+++ b/src/features/surface-host/ui/Number.svelte
@@ -1,43 +1,43 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { NumberFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { NumberFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: NumberFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric
- // input (the backend also floors/validates); send the new number as payload.
- function commit(event: Event) {
- const target = event.target as HTMLInputElement;
- const next = target.valueAsNumber;
- if (Number.isNaN(next)) return;
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: next,
- });
- }
+ // Commit on change/Enter rather than every keystroke. Ignore empty/non-numeric
+ // input (the backend also floors/validates); send the new number as payload.
+ function commit(event: Event) {
+ const target = event.target as HTMLInputElement;
+ const next = target.valueAsNumber;
+ if (Number.isNaN(next)) return;
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: next,
+ });
+ }
</script>
<label class="flex items-center justify-between gap-2 text-sm">
- <span>{field.label}</span>
- <span class="flex items-center gap-1">
- <input
- type="number"
- class="input input-bordered input-sm w-24"
- value={field.value}
- min={field.min}
- max={field.max}
- step={field.step}
- onchange={commit}
- />
- {#if field.unit}
- <span class="opacity-60">{field.unit}</span>
- {/if}
- </span>
+ <span>{field.label}</span>
+ <span class="flex items-center gap-1">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ value={field.value}
+ min={field.min}
+ max={field.max}
+ step={field.step}
+ onchange={commit}
+ />
+ {#if field.unit}
+ <span class="opacity-60">{field.unit}</span>
+ {/if}
+ </span>
</label>
diff --git a/src/features/surface-host/ui/Progress.svelte b/src/features/surface-host/ui/Progress.svelte
index cba9e0f..e291c79 100644
--- a/src/features/surface-host/ui/Progress.svelte
+++ b/src/features/surface-host/ui/Progress.svelte
@@ -1,13 +1,13 @@
<script lang="ts">
- import type { ProgressFieldView } from "../logic/types";
+ import type { ProgressFieldView } from "../logic/types";
- let { field }: { field: ProgressFieldView } = $props();
+ let { field }: { field: ProgressFieldView } = $props();
- const percent = $derived(Math.round(field.value * 100));
+ const percent = $derived(Math.round(field.value * 100));
</script>
<div>
- <span>{field.label}</span>
- <progress max="100" value={percent}>{percent}%</progress>
- <span>{percent}%</span>
+ <span>{field.label}</span>
+ <progress max="100" value={percent}>{percent}%</progress>
+ <span>{percent}%</span>
</div>
diff --git a/src/features/surface-host/ui/Selector.svelte b/src/features/surface-host/ui/Selector.svelte
index 2da104f..4cb3536 100644
--- a/src/features/surface-host/ui/Selector.svelte
+++ b/src/features/surface-host/ui/Selector.svelte
@@ -1,32 +1,32 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { SelectorFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { SelectorFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: SelectorFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleChange(event: Event) {
- const target = event.target as HTMLSelectElement;
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: target.value,
- });
- }
+ function handleChange(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: target.value,
+ });
+ }
</script>
<label>
- {field.label}
- <select onchange={handleChange}>
- {#each field.options as option (option.value)}
- <option value={option.value} selected={option.value === field.value}>
- {option.label}
- </option>
- {/each}
- </select>
+ {field.label}
+ <select onchange={handleChange}>
+ {#each field.options as option (option.value)}
+ <option value={option.value} selected={option.value === field.value}>
+ {option.label}
+ </option>
+ {/each}
+ </select>
</label>
diff --git a/src/features/surface-host/ui/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte
index 415423f..c559352 100644
--- a/src/features/surface-host/ui/StatTable.svelte
+++ b/src/features/surface-host/ui/StatTable.svelte
@@ -1,21 +1,21 @@
<script lang="ts">
- import type { StatFieldView } from "../logic/types";
+ import type { StatFieldView } from "../logic/types";
- // Renders a run of stat fields as one aligned label/value table. Headerless:
- // the column semantics aren't known generically, but the two-column layout
- // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
- let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
+ // Renders a run of stat fields as one aligned label/value table. Headerless:
+ // the column semantics aren't known generically, but the two-column layout
+ // gives the tidy, aligned readout the stats deserve (e.g. extension → version).
+ let { stats }: { readonly stats: readonly StatFieldView[] } = $props();
</script>
<div class="overflow-x-auto">
- <table class="table table-sm">
- <tbody>
- {#each stats as stat, i (i)}
- <tr>
- <th class="font-medium">{stat.label}</th>
- <td class="text-right tabular-nums">{stat.value}</td>
- </tr>
- {/each}
- </tbody>
- </table>
+ <table class="table table-sm">
+ <tbody>
+ {#each stats as stat, i (i)}
+ <tr>
+ <th class="font-medium">{stat.label}</th>
+ <td class="text-right tabular-nums">{stat.value}</td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
</div>
diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte
index 764cc36..e47c122 100644
--- a/src/features/surface-host/ui/SurfaceTable.svelte
+++ b/src/features/surface-host/ui/SurfaceTable.svelte
@@ -1,14 +1,14 @@
<script lang="ts">
- import Table from "../../../components/Table.svelte";
- import { parseTablePayload } from "../logic/table";
+ import Table from "../../../components/Table.svelte";
+ import { parseTablePayload } from "../logic/table";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- // Parse defensively; an unparseable payload yields null → render nothing
- // (graceful skip, per the custom-field contract).
- const data = $derived(parseTablePayload(payload));
+ // Parse defensively; an unparseable payload yields null → render nothing
+ // (graceful skip, per the custom-field contract).
+ const data = $derived(parseTablePayload(payload));
</script>
{#if data !== null}
- <Table columns={data.columns} rows={data.rows} />
+ <Table columns={data.columns} rows={data.rows} />
{/if}
diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte
index 3f92e3b..aed8d03 100644
--- a/src/features/surface-host/ui/SurfaceView.svelte
+++ b/src/features/surface-host/ui/SurfaceView.svelte
@@ -1,52 +1,49 @@
<script lang="ts">
- import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
- import { groupRenderFields, planSurface } from "../logic/plan";
- import Button from "./Button.svelte";
- import MessageQueueList from "./MessageQueueList.svelte";
- import Number from "./Number.svelte";
- import Progress from "./Progress.svelte";
- import Selector from "./Selector.svelte";
- import StatTable from "./StatTable.svelte";
- import SurfaceTable from "./SurfaceTable.svelte";
- import TodoList from "./TodoList.svelte";
- import Toggle from "./Toggle.svelte";
+ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract";
+ import { groupRenderFields, planSurface } from "../logic/plan";
+ import Button from "./Button.svelte";
+ import MessageQueueList from "./MessageQueueList.svelte";
+ import Number from "./Number.svelte";
+ import Progress from "./Progress.svelte";
+ import Selector from "./Selector.svelte";
+ import StatTable from "./StatTable.svelte";
+ import SurfaceTable from "./SurfaceTable.svelte";
+ import TodoList from "./TodoList.svelte";
+ import Toggle from "./Toggle.svelte";
- let {
- spec,
- onInvoke,
- }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
+ let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props();
- const plan = $derived(planSurface(spec));
- // Consecutive stats render together as one aligned table; everything else is
- // a standalone widget. Grouping keys on field KIND only — never the surface id.
- const groups = $derived(groupRenderFields(plan.fields));
+ const plan = $derived(planSurface(spec));
+ // Consecutive stats render together as one aligned table; everything else is
+ // a standalone widget. Grouping keys on field KIND only — never the surface id.
+ const groups = $derived(groupRenderFields(plan.fields));
</script>
<article>
- <h2>{spec.title}</h2>
- {#each groups as group, i (i)}
- {#if group.type === "stats"}
- <StatTable stats={group.stats} />
- {:else if group.field.kind === "toggle"}
- <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "progress"}
- <Progress field={group.field} />
- {:else if group.field.kind === "selector"}
- <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "number"}
- <Number field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "button"}
- <Button field={group.field} surfaceId={spec.id} {onInvoke} />
- {:else if group.field.kind === "custom"}
- <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
- unknown ids gracefully render nothing. -->
- {#if group.field.rendererId === "table"}
- <SurfaceTable payload={group.field.payload} />
- {:else if group.field.rendererId === "message-queue"}
- <MessageQueueList payload={group.field.payload} />
- {:else if group.field.rendererId === "todo"}
- <TodoList payload={group.field.payload} />
- {/if}
- {/if}
- {/each}
+ <h2>{spec.title}</h2>
+ {#each groups as group, i (i)}
+ {#if group.type === "stats"}
+ <StatTable stats={group.stats} />
+ {:else if group.field.kind === "toggle"}
+ <Toggle field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "progress"}
+ <Progress field={group.field} />
+ {:else if group.field.kind === "selector"}
+ <Selector field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "number"}
+ <Number field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "button"}
+ <Button field={group.field} surfaceId={spec.id} {onInvoke} />
+ {:else if group.field.kind === "custom"}
+ <!-- Dispatch on rendererId (a renderer KIND, never a surface id);
+ unknown ids gracefully render nothing. -->
+ {#if group.field.rendererId === "table"}
+ <SurfaceTable payload={group.field.payload} />
+ {:else if group.field.rendererId === "message-queue"}
+ <MessageQueueList payload={group.field.payload} />
+ {:else if group.field.rendererId === "todo"}
+ <TodoList payload={group.field.payload} />
+ {/if}
+ {/if}
+ {/each}
</article>
diff --git a/src/features/surface-host/ui/TodoList.svelte b/src/features/surface-host/ui/TodoList.svelte
index b7b2183..e9b1b58 100644
--- a/src/features/surface-host/ui/TodoList.svelte
+++ b/src/features/surface-host/ui/TodoList.svelte
@@ -1,61 +1,61 @@
<script lang="ts">
- import { parseTodoPayload } from "../logic/todo";
+ import { parseTodoPayload } from "../logic/todo";
- let { payload }: { readonly payload: unknown } = $props();
+ let { payload }: { readonly payload: unknown } = $props();
- const data = $derived(parseTodoPayload(payload));
+ const data = $derived(parseTodoPayload(payload));
</script>
{#if data !== null && data.todos.length > 0}
- <ul class="flex flex-col gap-1">
- {#each data.todos as todo, i (i)}
- <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm">
- <!-- Status indicator -->
- <span class="mt-0.5 shrink-0">
- {#if todo.status === "in_progress"}
- <span class="block h-4 w-4 rounded-full bg-primary"></span>
- {:else if todo.status === "completed"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-success"
- >
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {:else if todo.status === "cancelled"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="3"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-base-content/40"
- >
- <line x1="18" y1="6" x2="6" y2="18"></line>
- <line x1="6" y1="6" x2="18" y2="18"></line>
- </svg>
- {:else}
- <!-- pending: empty circle -->
- <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span>
- {/if}
- </span>
+ <ul class="flex flex-col gap-1">
+ {#each data.todos as todo, i (i)}
+ <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2 text-sm">
+ <!-- Status indicator -->
+ <span class="mt-0.5 shrink-0">
+ {#if todo.status === "in_progress"}
+ <span class="block h-4 w-4 rounded-full bg-primary"></span>
+ {:else if todo.status === "completed"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {:else if todo.status === "cancelled"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-base-content/40"
+ >
+ <line x1="18" y1="6" x2="6" y2="18"></line>
+ <line x1="6" y1="6" x2="18" y2="18"></line>
+ </svg>
+ {:else}
+ <!-- pending: empty circle -->
+ <span class="block h-4 w-4 rounded-full border-2 border-base-content/30"></span>
+ {/if}
+ </span>
- <!-- Content -->
- <span
- class:flex-1={true}
- class:line-through={todo.status === "completed" || todo.status === "cancelled"}
- class:opacity-50={todo.status === "completed" || todo.status === "cancelled"}
- >
- {todo.content}
- </span>
- </li>
- {/each}
- </ul>
+ <!-- Content -->
+ <span
+ class:flex-1={true}
+ class:line-through={todo.status === "completed" || todo.status === "cancelled"}
+ class:opacity-50={todo.status === "completed" || todo.status === "cancelled"}
+ >
+ {todo.content}
+ </span>
+ </li>
+ {/each}
+ </ul>
{/if}
diff --git a/src/features/surface-host/ui/Toggle.svelte b/src/features/surface-host/ui/Toggle.svelte
index aec8f4e..0326851 100644
--- a/src/features/surface-host/ui/Toggle.svelte
+++ b/src/features/surface-host/ui/Toggle.svelte
@@ -1,25 +1,25 @@
<script lang="ts">
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import type { ToggleFieldView } from "../logic/types";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import type { ToggleFieldView } from "../logic/types";
- let {
- field,
- surfaceId,
- onInvoke,
- }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
- $props();
+ let {
+ field,
+ surfaceId,
+ onInvoke,
+ }: { field: ToggleFieldView; surfaceId: string; onInvoke: (msg: InvokeMessage) => void } =
+ $props();
- function handleChange() {
- onInvoke({
- type: "invoke",
- surfaceId,
- actionId: field.action.actionId,
- payload: !field.value,
- });
- }
+ function handleChange() {
+ onInvoke({
+ type: "invoke",
+ surfaceId,
+ actionId: field.action.actionId,
+ payload: !field.value,
+ });
+ }
</script>
<label>
- <input type="checkbox" checked={field.value} onchange={handleChange} />
- {field.label}
+ <input type="checkbox" checked={field.value} onchange={handleChange} />
+ {field.label}
</label>
diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts
index 7c77675..661b341 100644
--- a/src/features/system-prompt/index.ts
+++ b/src/features/system-prompt/index.ts
@@ -1,17 +1,17 @@
export type {
- LoadSystemPrompt,
- LoadSystemPromptVariables,
- SaveSystemPrompt,
- SystemPromptLoadResult,
- SystemPromptSaveResult,
- SystemPromptVariablesResult,
- VariableGroup,
+ LoadSystemPrompt,
+ LoadSystemPromptVariables,
+ SaveSystemPrompt,
+ SystemPromptLoadResult,
+ SystemPromptSaveResult,
+ SystemPromptVariablesResult,
+ VariableGroup,
} from "./logic/view-model";
export { buildTag, groupVariables, insertTag } from "./logic/view-model";
export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "system-prompt",
- description: "Global system prompt template builder with variable placeholders",
+ name: "system-prompt",
+ description: "Global system prompt template builder with variable placeholders",
} as const;
diff --git a/src/features/system-prompt/logic/view-model.test.ts b/src/features/system-prompt/logic/view-model.test.ts
index a0736cc..223327b 100644
--- a/src/features/system-prompt/logic/view-model.test.ts
+++ b/src/features/system-prompt/logic/view-model.test.ts
@@ -1,90 +1,90 @@
import type { SystemPromptVariable } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import {
- buildIfNotTag,
- buildIfTag,
- buildTag,
- groupVariables,
- insertTag,
- isDynamicVariable,
+ buildIfNotTag,
+ buildIfTag,
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
} from "./view-model";
describe("system-prompt view-model", () => {
- describe("buildTag", () => {
- it("builds a variable placeholder", () => {
- expect(buildTag("system", "time")).toBe("[system:time]");
- });
- });
+ describe("buildTag", () => {
+ it("builds a variable placeholder", () => {
+ expect(buildTag("system", "time")).toBe("[system:time]");
+ });
+ });
- describe("buildIfTag", () => {
- it("builds an opening conditional tag", () => {
- expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]");
- });
- });
+ describe("buildIfTag", () => {
+ it("builds an opening conditional tag", () => {
+ expect(buildIfTag("file", "AGENTS.md")).toBe("[if file:AGENTS.md]");
+ });
+ });
- describe("buildIfNotTag", () => {
- it("builds a negated opening conditional tag", () => {
- expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]");
- });
- });
+ describe("buildIfNotTag", () => {
+ it("builds a negated opening conditional tag", () => {
+ expect(buildIfNotTag("prompt", "cwd")).toBe("[if !prompt:cwd]");
+ });
+ });
- describe("insertTag", () => {
- it("inserts a tag at the cursor position", () => {
- expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({
- template: "Hello[system:time] world",
- cursor: 18,
- });
- });
+ describe("insertTag", () => {
+ it("inserts a tag at the cursor position", () => {
+ expect(insertTag("Hello world", "[system:time]", 5, 5)).toEqual({
+ template: "Hello[system:time] world",
+ cursor: 18,
+ });
+ });
- it("replaces the selected text", () => {
- const tag = buildTag("file", "README.md");
- expect(insertTag("Hello world", tag, 6, 11)).toEqual({
- template: `Hello ${tag}`,
- cursor: 6 + tag.length,
- });
- });
+ it("replaces the selected text", () => {
+ const tag = buildTag("file", "README.md");
+ expect(insertTag("Hello world", tag, 6, 11)).toEqual({
+ template: `Hello ${tag}`,
+ cursor: 6 + tag.length,
+ });
+ });
- it("inserts at the end", () => {
- const tag = buildTag("git", "branch");
- expect(insertTag("", tag, 0, 0)).toEqual({
- template: tag,
- cursor: tag.length,
- });
- });
- });
+ it("inserts at the end", () => {
+ const tag = buildTag("git", "branch");
+ expect(insertTag("", tag, 0, 0)).toEqual({
+ template: tag,
+ cursor: tag.length,
+ });
+ });
+ });
- describe("groupVariables", () => {
- it("groups by type in first-appearing order", () => {
- const variables: SystemPromptVariable[] = [
- { type: "system", name: "time", description: "" },
- { type: "prompt", name: "cwd", description: "" },
- { type: "system", name: "date", description: "" },
- { type: "git", name: "branch", description: "" },
- ];
- const groups = groupVariables(variables);
- expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]);
- expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]);
- expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]);
- expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]);
- });
+ describe("groupVariables", () => {
+ it("groups by type in first-appearing order", () => {
+ const variables: SystemPromptVariable[] = [
+ { type: "system", name: "time", description: "" },
+ { type: "prompt", name: "cwd", description: "" },
+ { type: "system", name: "date", description: "" },
+ { type: "git", name: "branch", description: "" },
+ ];
+ const groups = groupVariables(variables);
+ expect(groups.map((g) => g.type)).toEqual(["system", "prompt", "git"]);
+ expect(groups[0]?.variables.map((v) => v.name)).toEqual(["time", "date"]);
+ expect(groups[1]?.variables.map((v) => v.name)).toEqual(["cwd"]);
+ expect(groups[2]?.variables.map((v) => v.name)).toEqual(["branch"]);
+ });
- it("returns an empty array when no variables", () => {
- expect(groupVariables([])).toEqual([]);
- });
- });
+ it("returns an empty array when no variables", () => {
+ expect(groupVariables([])).toEqual([]);
+ });
+ });
- describe("isDynamicVariable", () => {
- it("returns true when dynamic is true", () => {
- expect(
- isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }),
- ).toBe(true);
- });
+ describe("isDynamicVariable", () => {
+ it("returns true when dynamic is true", () => {
+ expect(
+ isDynamicVariable({ type: "file", name: "path", description: "", dynamic: true }),
+ ).toBe(true);
+ });
- it("returns false when dynamic is missing or false", () => {
- expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false);
- expect(
- isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }),
- ).toBe(false);
- });
- });
+ it("returns false when dynamic is missing or false", () => {
+ expect(isDynamicVariable({ type: "system", name: "time", description: "" })).toBe(false);
+ expect(
+ isDynamicVariable({ type: "system", name: "time", description: "", dynamic: false }),
+ ).toBe(false);
+ });
+ });
});
diff --git a/src/features/system-prompt/logic/view-model.ts b/src/features/system-prompt/logic/view-model.ts
index 6924208..e202ee3 100644
--- a/src/features/system-prompt/logic/view-model.ts
+++ b/src/features/system-prompt/logic/view-model.ts
@@ -14,20 +14,20 @@ import type { SystemPromptVariable } from "@dispatch/transport-contract";
// ── Injected ports (composition root adapts the store to these shapes) ─────────
export type SystemPromptLoadResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
export type LoadSystemPrompt = () => Promise<SystemPromptLoadResult>;
export type SystemPromptSaveResult =
- | { readonly ok: true; readonly template: string }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly template: string }
+ | { readonly ok: false; readonly error: string };
export type SaveSystemPrompt = (template: string) => Promise<SystemPromptSaveResult>;
export type SystemPromptVariablesResult =
- | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] }
+ | { readonly ok: false; readonly error: string };
export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResult>;
@@ -35,24 +35,24 @@ export type LoadSystemPromptVariables = () => Promise<SystemPromptVariablesResul
/** Build the literal placeholder `[type:name]` for a variable. */
export function buildTag(type: string, name: string): string {
- return `[${type}:${name}]`;
+ return `[${type}:${name}]`;
}
/** Build the literal placeholder `[if type:name]` for a conditional block. */
export function buildIfTag(type: string, name: string): string {
- return `[if ${type}:${name}]`;
+ return `[if ${type}:${name}]`;
}
/** Build the literal placeholder `[if !type:name]` for a negated conditional block. */
export function buildIfNotTag(type: string, name: string): string {
- return `[if !${type}:${name}]`;
+ return `[if !${type}:${name}]`;
}
export interface Insertion {
- /** Template text after insertion. */
- template: string;
- /** New cursor position (caret index) after insertion. */
- cursor: number;
+ /** Template text after insertion. */
+ template: string;
+ /** New cursor position (caret index) after insertion. */
+ cursor: number;
}
/**
@@ -61,24 +61,24 @@ export interface Insertion {
* the caller can restore the caret after the inserted tag.
*/
export function insertTag(
- template: string,
- tag: string,
- selectionStart: number,
- selectionEnd: number,
+ template: string,
+ tag: string,
+ selectionStart: number,
+ selectionEnd: number,
): Insertion {
- const before = template.slice(0, selectionStart);
- const after = template.slice(selectionEnd);
- const next = before + tag + after;
- return { template: next, cursor: selectionStart + tag.length };
+ const before = template.slice(0, selectionStart);
+ const after = template.slice(selectionEnd);
+ const next = before + tag + after;
+ return { template: next, cursor: selectionStart + tag.length };
}
// ── Variable grouping ─────────────────────────────────────────────────────────
export interface VariableGroup {
- /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
- readonly type: string;
- /** Variables of this type. */
- readonly variables: readonly SystemPromptVariable[];
+ /** Variable type (e.g. `"system"`, `"file"`, `"prompt"`, `"git"`). */
+ readonly type: string;
+ /** Variables of this type. */
+ readonly variables: readonly SystemPromptVariable[];
}
/**
@@ -86,21 +86,21 @@ export interface VariableGroup {
* group and the order of first-appearing types.
*/
export function groupVariables(
- variables: readonly SystemPromptVariable[],
+ variables: readonly SystemPromptVariable[],
): readonly VariableGroup[] {
- const order: string[] = [];
- const map = new Map<string, SystemPromptVariable[]>();
- for (const v of variables) {
- if (!map.has(v.type)) {
- map.set(v.type, []);
- order.push(v.type);
- }
- map.get(v.type)?.push(v);
- }
- return order.map((type) => ({ type, variables: map.get(type) ?? [] }));
+ const order: string[] = [];
+ const map = new Map<string, SystemPromptVariable[]>();
+ for (const v of variables) {
+ if (!map.has(v.type)) {
+ map.set(v.type, []);
+ order.push(v.type);
+ }
+ map.get(v.type)?.push(v);
+ }
+ return order.map((type) => ({ type, variables: map.get(type) ?? [] }));
}
/** Whether a variable is "dynamic" (any name is valid, e.g. `file:<path>`). */
export function isDynamicVariable(variable: SystemPromptVariable): boolean {
- return variable.dynamic === true;
+ return variable.dynamic === true;
}
diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
index 4e07317..2e92e26 100644
--- a/src/features/system-prompt/ui/SystemPromptBuilder.svelte
+++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte
@@ -1,242 +1,243 @@
<script lang="ts">
- import type { SystemPromptVariable } from "@dispatch/transport-contract";
- import { tick, untrack } from "svelte";
- import {
- buildTag,
- groupVariables,
- insertTag,
- isDynamicVariable,
- type LoadSystemPrompt,
- type LoadSystemPromptVariables,
- type SaveSystemPrompt,
- } from "../logic/view-model";
-
- let {
- loadPrompt,
- savePrompt,
- loadVariables,
- onClose,
- }: {
- loadPrompt: LoadSystemPrompt;
- savePrompt: SaveSystemPrompt;
- loadVariables: LoadSystemPromptVariables;
- onClose: () => void;
- } = $props();
-
- let value = $state("");
- let loadedTemplate = $state("");
- let loading = $state(false);
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
- let variables = $state<readonly SystemPromptVariable[]>([]);
- let filePath = $state("");
- let textarea: HTMLTextAreaElement | null = null;
-
- const groups = $derived(groupVariables(variables));
- const hasChanges = $derived(value !== loadedTemplate);
-
- async function load() {
- untrack(() => {
- loading = true;
- error = null;
- });
-
- const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
-
- loading = false;
-
- if (!templateResult.ok || !variablesResult.ok) {
- const parts: string[] = [];
- if (!templateResult.ok) parts.push(templateResult.error);
- if (!variablesResult.ok) parts.push(variablesResult.error);
- error = parts.join("; ");
- return;
- }
-
- value = templateResult.template;
- loadedTemplate = templateResult.template;
- variables = variablesResult.variables;
- }
-
- async function save() {
- if (saving || loading) return;
- saving = true;
- error = null;
- justSaved = false;
- const result = await savePrompt(value);
- saving = false;
- if (!result.ok) {
- error = result.error;
- return;
- }
- loadedTemplate = result.template;
- value = result.template;
- justSaved = true;
- }
-
- function reset() {
- value = loadedTemplate;
- error = null;
- justSaved = false;
- }
-
- async function insertTagAtCursor(tag: string) {
- if (textarea === null) return;
- const start = textarea.selectionStart;
- const end = textarea.selectionEnd;
- const insertion = insertTag(value, tag, start, end);
- value = insertion.template;
- await tick();
- textarea.focus();
- textarea.setSelectionRange(insertion.cursor, insertion.cursor);
- }
-
- async function insertFileVariable(type: string) {
- const path = filePath.trim();
- if (path.length === 0) return;
- await insertTagAtCursor(buildTag(type, path));
- filePath = "";
- }
-
- function onKeydown(e: KeyboardEvent) {
- if (e.key === "Escape") onClose();
- }
-
- // Load on mount once.
- $effect(() => {
- void load();
- });
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPrompt,
+ type LoadSystemPromptVariables,
+ type SaveSystemPrompt,
+ } from "../logic/view-model";
+
+ let {
+ loadPrompt,
+ savePrompt,
+ loadVariables,
+ onClose,
+ }: {
+ loadPrompt: LoadSystemPrompt;
+ savePrompt: SaveSystemPrompt;
+ loadVariables: LoadSystemPromptVariables;
+ onClose: () => void;
+ } = $props();
+
+ let value = $state("");
+ let loadedTemplate = $state("");
+ let loading = $state(false);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let filePath = $state("");
+ let textarea: HTMLTextAreaElement | null = null;
+
+ const groups = $derived(groupVariables(variables));
+ const hasChanges = $derived(value !== loadedTemplate);
+
+ async function load() {
+ untrack(() => {
+ loading = true;
+ error = null;
+ });
+
+ const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]);
+
+ loading = false;
+
+ if (!templateResult.ok || !variablesResult.ok) {
+ const parts: string[] = [];
+ if (!templateResult.ok) parts.push(templateResult.error);
+ if (!variablesResult.ok) parts.push(variablesResult.error);
+ error = parts.join("; ");
+ return;
+ }
+
+ value = templateResult.template;
+ loadedTemplate = templateResult.template;
+ variables = variablesResult.variables;
+ }
+
+ async function save() {
+ if (saving || loading) return;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await savePrompt(value);
+ saving = false;
+ if (!result.ok) {
+ error = result.error;
+ return;
+ }
+ loadedTemplate = result.template;
+ value = result.template;
+ justSaved = true;
+ }
+
+ function reset() {
+ value = loadedTemplate;
+ error = null;
+ justSaved = false;
+ }
+
+ async function insertTagAtCursor(tag: string) {
+ if (textarea === null) return;
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const insertion = insertTag(value, tag, start, end);
+ value = insertion.template;
+ await tick();
+ textarea.focus();
+ textarea.setSelectionRange(insertion.cursor, insertion.cursor);
+ }
+
+ async function insertFileVariable(type: string) {
+ const path = filePath.trim();
+ if (path.length === 0) return;
+ await insertTagAtCursor(buildTag(type, path));
+ filePath = "";
+ }
+
+ function onKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load on mount once.
+ $effect(() => {
+ void load();
+ });
</script>
<svelte:window onkeydown={onKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
- role="dialog"
- aria-modal="true"
- aria-label="System prompt builder"
- tabindex="-1"
- onclick={onClose}
- onkeydown={onKeydown}
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="System prompt builder"
+ tabindex="-1"
+ onclick={onClose}
+ onkeydown={onKeydown}
>
- <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
- <div
- class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
- onclick={(e) => e.stopPropagation()}
- >
- <!-- Header -->
- <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
- <div class="flex items-center gap-2">
- <h2 class="text-sm font-semibold">System Prompt</h2>
- {#if loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
- <button
- type="button"
- class="btn btn-ghost btn-sm btn-square"
- onclick={onClose}
- aria-label="Close system prompt builder"
- >
- ✕
- </button>
- </div>
-
- <!-- Body: half editor / half variables -->
- <div class="flex min-h-0 flex-1">
- <!-- Left: template editor -->
- <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
- <textarea
- bind:this={textarea}
- bind:value
- class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
- placeholder={loading ? "Loading template..." : "Edit the global system prompt template..."}
- disabled={loading}
- aria-label="System prompt template"
- ></textarea>
-
- <div class="flex shrink-0 items-center gap-2">
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={loading || saving || !hasChanges}
- onclick={save}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- disabled={loading || !hasChanges}
- onclick={reset}
- >
- Reset
- </button>
- {#if justSaved && !hasChanges}
- <span class="text-xs text-success">Saved.</span>
- {:else if hasChanges}
- <span class="text-xs opacity-60">Unsaved changes</span>
- {/if}
- </div>
-
- {#if error}
- <p class="shrink-0 text-xs text-error">{error}</p>
- {/if}
- </div>
-
- <!-- Right: variable palette -->
- <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
- <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
- {#if groups.length === 0 && !loading}
- <p class="text-xs opacity-60">No variables available.</p>
- {/if}
- <div class="flex flex-col gap-3">
- {#each groups as group (group.type)}
- <div class="rounded-box bg-base-200 p-3">
- <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
- <div class="mt-2 flex flex-wrap gap-1">
- {#each group.variables as variable (variable.type + variable.name)}
- {#if isDynamicVariable(variable)}
- <div class="flex items-center gap-1">
- <input
- type="text"
- class="input input-bordered input-xs w-32 font-mono"
- bind:value={filePath}
- placeholder={variable.name}
- onkeydown={(e) => {
- if (e.key === "Enter") void insertFileVariable(variable.type);
- }}
- />
- <button
- type="button"
- class="btn btn-xs"
- onclick={() => void insertFileVariable(variable.type)}
- >
- Insert
- </button>
- </div>
- {:else}
- <button
- type="button"
- class="btn btn-xs"
- title={variable.description}
- onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
- >
- {variable.name}
- </button>
- {/if}
- {/each}
- </div>
- </div>
- {/each}
- </div>
- </div>
- </div>
- </div>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div
+ class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <!-- Header -->
+ <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <h2 class="text-sm font-semibold">System Prompt</h2>
+ {#if loading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ onclick={onClose}
+ aria-label="Close system prompt builder"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- Left: template editor -->
+ <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
+ <textarea
+ bind:this={textarea}
+ bind:value
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder={loading
+ ? "Loading template..."
+ : "Edit the global system prompt template..."}
+ disabled={loading}
+ aria-label="System prompt template"></textarea>
+
+ <div class="flex shrink-0 items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={loading || saving || !hasChanges}
+ onclick={save}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ disabled={loading || !hasChanges}
+ onclick={reset}
+ >
+ Reset
+ </button>
+ {#if justSaved && !hasChanges}
+ <span class="text-xs text-success">Saved.</span>
+ {:else if hasChanges}
+ <span class="text-xs opacity-60">Unsaved changes</span>
+ {/if}
+ </div>
+
+ {#if error}
+ <p class="shrink-0 text-xs text-error">{error}</p>
+ {/if}
+ </div>
+
+ <!-- Right: variable palette -->
+ <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
+ <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
+ {#if groups.length === 0 && !loading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {/if}
+ <div class="flex flex-col gap-3">
+ {#each groups as group (group.type)}
+ <div class="rounded-box bg-base-200 p-3">
+ <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
+ <div class="mt-2 flex flex-wrap gap-1">
+ {#each group.variables as variable (variable.type + variable.name)}
+ {#if isDynamicVariable(variable)}
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ bind:value={filePath}
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") void insertFileVariable(variable.type);
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={() => void insertFileVariable(variable.type)}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ </div>
</div>
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 699c845..6ac90a3 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,16 +1,16 @@
export type { Tab, TabsState } from "./tabs";
export {
- activeTab,
- closeTab,
- createTab,
- deriveTitle,
- initialState,
- MIN_HANDLE_LENGTH,
- newDraft,
- selectTab,
- setModel,
- setTitle,
- shortHandle,
+ activeTab,
+ closeTab,
+ createTab,
+ deriveTitle,
+ initialState,
+ MIN_HANDLE_LENGTH,
+ newDraft,
+ selectTab,
+ setModel,
+ setTitle,
+ shortHandle,
} from "./tabs";
export type { TabsStorage, TabsStore } from "./tabs-store.svelte";
export { createTabsStore } from "./tabs-store.svelte";
@@ -18,6 +18,6 @@ export { default as TabBar } from "./ui/TabBar.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "tabs",
- description: "Conversation tabs with title derivation and persistence",
+ name: "tabs",
+ description: "Conversation tabs with title derivation and persistence",
} as const;
diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts
index 2e876f9..d044b1f 100644
--- a/src/features/tabs/tabs-store.svelte.ts
+++ b/src/features/tabs/tabs-store.svelte.ts
@@ -1,73 +1,73 @@
import type { Tab, TabsState } from "./tabs";
import {
- initialState,
- closeTab as reduceCloseTab,
- createTab as reduceCreateTab,
- newDraft as reduceNewDraft,
- openTab as reduceOpenTab,
- selectTab as reduceSelectTab,
- setModel as reduceSetModel,
- setTitle as reduceSetTitle,
- activeTab as selectActiveTab,
+ initialState,
+ closeTab as reduceCloseTab,
+ createTab as reduceCreateTab,
+ newDraft as reduceNewDraft,
+ openTab as reduceOpenTab,
+ selectTab as reduceSelectTab,
+ setModel as reduceSetModel,
+ setTitle as reduceSetTitle,
+ activeTab as selectActiveTab,
} from "./tabs";
export interface TabsStorage {
- load(): TabsState | null;
- save(state: TabsState): void;
+ load(): TabsState | null;
+ save(state: TabsState): void;
}
export interface TabsStore {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
- readonly activeTab: Tab | null;
- newDraft(): void;
- createTab(tab: Tab): void;
- /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */
- openTab(tab: Tab): void;
- selectTab(conversationId: string): void;
- closeTab(conversationId: string): void;
- setModel(conversationId: string, model: string): void;
- setTitle(conversationId: string, title: string): void;
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
+ readonly activeTab: Tab | null;
+ newDraft(): void;
+ createTab(tab: Tab): void;
+ /** Add a tab WITHOUT focusing it (for `conversation.open`). No-op if already open. */
+ openTab(tab: Tab): void;
+ selectTab(conversationId: string): void;
+ closeTab(conversationId: string): void;
+ setModel(conversationId: string, model: string): void;
+ setTitle(conversationId: string, title: string): void;
}
export function createTabsStore(storage: TabsStorage): TabsStore {
- let state = $state<TabsState>(storage.load() ?? initialState());
+ let state = $state<TabsState>(storage.load() ?? initialState());
- function apply(next: TabsState): void {
- state = next;
- storage.save(next);
- }
+ function apply(next: TabsState): void {
+ state = next;
+ storage.save(next);
+ }
- return {
- get tabs(): readonly Tab[] {
- return state.tabs;
- },
- get activeConversationId(): string | null {
- return state.activeConversationId;
- },
- get activeTab(): Tab | null {
- return selectActiveTab(state);
- },
- newDraft(): void {
- apply(reduceNewDraft(state));
- },
- createTab(tab: Tab): void {
- apply(reduceCreateTab(state, tab));
- },
- openTab(tab: Tab): void {
- apply(reduceOpenTab(state, tab));
- },
- selectTab(conversationId: string): void {
- apply(reduceSelectTab(state, conversationId));
- },
- closeTab(conversationId: string): void {
- apply(reduceCloseTab(state, conversationId));
- },
- setModel(conversationId: string, model: string): void {
- apply(reduceSetModel(state, conversationId, model));
- },
- setTitle(conversationId: string, title: string): void {
- apply(reduceSetTitle(state, conversationId, title));
- },
- };
+ return {
+ get tabs(): readonly Tab[] {
+ return state.tabs;
+ },
+ get activeConversationId(): string | null {
+ return state.activeConversationId;
+ },
+ get activeTab(): Tab | null {
+ return selectActiveTab(state);
+ },
+ newDraft(): void {
+ apply(reduceNewDraft(state));
+ },
+ createTab(tab: Tab): void {
+ apply(reduceCreateTab(state, tab));
+ },
+ openTab(tab: Tab): void {
+ apply(reduceOpenTab(state, tab));
+ },
+ selectTab(conversationId: string): void {
+ apply(reduceSelectTab(state, conversationId));
+ },
+ closeTab(conversationId: string): void {
+ apply(reduceCloseTab(state, conversationId));
+ },
+ setModel(conversationId: string, model: string): void {
+ apply(reduceSetModel(state, conversationId, model));
+ },
+ setTitle(conversationId: string, title: string): void {
+ apply(reduceSetTitle(state, conversationId, title));
+ },
+ };
}
diff --git a/src/features/tabs/tabs-store.test.ts b/src/features/tabs/tabs-store.test.ts
index 71a38dc..bb4df98 100644
--- a/src/features/tabs/tabs-store.test.ts
+++ b/src/features/tabs/tabs-store.test.ts
@@ -4,154 +4,154 @@ import type { TabsStorage } from "./tabs-store.svelte";
import { createTabsStore } from "./tabs-store.svelte";
function createMemoryStorage(initial?: TabsState): TabsStorage & { data: TabsState | null } {
- let data: TabsState | null = initial ?? null;
- return {
- get data() {
- return data;
- },
- set data(v: TabsState | null) {
- data = v;
- },
- load() {
- return data;
- },
- save(state: TabsState) {
- data = state;
- },
- };
+ let data: TabsState | null = initial ?? null;
+ return {
+ get data() {
+ return data;
+ },
+ set data(v: TabsState | null) {
+ data = v;
+ },
+ load() {
+ return data;
+ },
+ save(state: TabsState) {
+ data = state;
+ },
+ };
}
describe("createTabsStore", () => {
- it("loads persisted state on construct", () => {
- const persisted: TabsState = {
- tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }],
- activeConversationId: "c1",
- };
- const storage = createMemoryStorage(persisted);
- const store = createTabsStore(storage);
-
- expect(store.tabs).toHaveLength(1);
- expect(store.activeConversationId).toBe("c1");
- expect(store.activeTab?.conversationId).toBe("c1");
- });
-
- it("starts with empty draft when no persisted state", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
- expect(store.activeTab).toBeNull();
- });
-
- it("saves after every mutation", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- expect(storage.data?.tabs).toHaveLength(1);
- expect(storage.data?.activeConversationId).toBe("c1");
-
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- expect(storage.data?.tabs).toHaveLength(2);
-
- store.selectTab("c1");
- expect(storage.data?.activeConversationId).toBe("c1");
-
- store.closeTab("c1");
- expect(storage.data?.tabs).toHaveLength(1);
- expect(storage.data?.activeConversationId).toBe("c2");
-
- store.setModel("c2", "new-model");
- expect(storage.data?.tabs[0]?.model).toBe("new-model");
-
- store.setTitle("c2", "New Title");
- expect(storage.data?.tabs[0]?.title).toBe("New Title");
-
- store.newDraft();
- expect(storage.data?.activeConversationId).toBeNull();
- });
-
- it("createTab appends and activates", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- expect(store.tabs).toHaveLength(1);
- expect(store.activeConversationId).toBe("c1");
-
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- expect(store.tabs).toHaveLength(2);
- expect(store.activeConversationId).toBe("c2");
- });
-
- it("selectTab changes active", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
-
- store.selectTab("c1");
- expect(store.activeConversationId).toBe("c1");
- });
-
- it("closeTab removes and activates neighbour", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" });
-
- store.selectTab("c2");
- store.closeTab("c2");
- expect(store.tabs).toHaveLength(2);
- expect(store.activeConversationId).toBe("c1");
- });
-
- it("closing the last tab returns to draft", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.closeTab("c1");
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
- });
-
- it("setModel updates the right tab", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
-
- store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
-
- store.setModel("c1", "new-model");
- expect(store.tabs[0]?.model).toBe("new-model");
- expect(store.tabs[1]?.model).toBe("m2");
- });
+ it("loads persisted state on construct", () => {
+ const persisted: TabsState = {
+ tabs: [{ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" }],
+ activeConversationId: "c1",
+ };
+ const storage = createMemoryStorage(persisted);
+ const store = createTabsStore(storage);
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.activeConversationId).toBe("c1");
+ expect(store.activeTab?.conversationId).toBe("c1");
+ });
+
+ it("starts with empty draft when no persisted state", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+ expect(store.activeTab).toBeNull();
+ });
+
+ it("saves after every mutation", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ expect(storage.data?.tabs).toHaveLength(1);
+ expect(storage.data?.activeConversationId).toBe("c1");
+
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ expect(storage.data?.tabs).toHaveLength(2);
+
+ store.selectTab("c1");
+ expect(storage.data?.activeConversationId).toBe("c1");
+
+ store.closeTab("c1");
+ expect(storage.data?.tabs).toHaveLength(1);
+ expect(storage.data?.activeConversationId).toBe("c2");
+
+ store.setModel("c2", "new-model");
+ expect(storage.data?.tabs[0]?.model).toBe("new-model");
+
+ store.setTitle("c2", "New Title");
+ expect(storage.data?.tabs[0]?.title).toBe("New Title");
+
+ store.newDraft();
+ expect(storage.data?.activeConversationId).toBeNull();
+ });
+
+ it("createTab appends and activates", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ expect(store.tabs).toHaveLength(1);
+ expect(store.activeConversationId).toBe("c1");
+
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBe("c2");
+ });
+
+ it("selectTab changes active", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+
+ store.selectTab("c1");
+ expect(store.activeConversationId).toBe("c1");
+ });
+
+ it("closeTab removes and activates neighbour", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ store.createTab({ conversationId: "c3", model: "m3", title: "T3", workspaceId: "default" });
+
+ store.selectTab("c2");
+ store.closeTab("c2");
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBe("c1");
+ });
+
+ it("closing the last tab returns to draft", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.closeTab("c1");
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+ });
+
+ it("setModel updates the right tab", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
+
+ store.createTab({ conversationId: "c1", model: "old", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+
+ store.setModel("c1", "new-model");
+ expect(store.tabs[0]?.model).toBe("new-model");
+ expect(store.tabs[1]?.model).toBe("m2");
+ });
- it("setTitle updates the right tab", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
+ it("setTitle updates the right tab", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "Old", workspaceId: "default" });
- store.setTitle("c1", "New Title");
- expect(store.tabs[0]?.title).toBe("New Title");
- });
+ store.setTitle("c1", "New Title");
+ expect(store.tabs[0]?.title).toBe("New Title");
+ });
- it("newDraft clears active but keeps tabs", () => {
- const storage = createMemoryStorage();
- const store = createTabsStore(storage);
+ it("newDraft clears active but keeps tabs", () => {
+ const storage = createMemoryStorage();
+ const store = createTabsStore(storage);
- store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
- store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
+ store.createTab({ conversationId: "c1", model: "m1", title: "T1", workspaceId: "default" });
+ store.createTab({ conversationId: "c2", model: "m2", title: "T2", workspaceId: "default" });
- store.newDraft();
- expect(store.tabs).toHaveLength(2);
- expect(store.activeConversationId).toBeNull();
- expect(store.activeTab).toBeNull();
- });
+ store.newDraft();
+ expect(store.tabs).toHaveLength(2);
+ expect(store.activeConversationId).toBeNull();
+ expect(store.activeTab).toBeNull();
+ });
});
diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts
index e3ca4fa..c31d2e7 100644
--- a/src/features/tabs/tabs.test.ts
+++ b/src/features/tabs/tabs.test.ts
@@ -1,249 +1,249 @@
import { describe, expect, it } from "vitest";
import type { Tab, TabsState } from "./tabs";
import {
- activeTab,
- closeTab,
- createTab,
- deriveTitle,
- initialState,
- isStuckToEnd,
- MIN_HANDLE_LENGTH,
- newDraft,
- selectTab,
- setModel,
- setTitle,
- shortHandle,
+ activeTab,
+ closeTab,
+ createTab,
+ deriveTitle,
+ initialState,
+ isStuckToEnd,
+ MIN_HANDLE_LENGTH,
+ newDraft,
+ selectTab,
+ setModel,
+ setTitle,
+ shortHandle,
} from "./tabs";
const tab = (conversationId: string, model = "default", title = "Chat"): Tab => ({
- conversationId,
- model,
- title,
- workspaceId: "default",
+ conversationId,
+ model,
+ title,
+ workspaceId: "default",
});
describe("initialState", () => {
- it("returns empty draft state when no persisted state", () => {
- const state = initialState();
- expect(state.tabs).toEqual([]);
- expect(state.activeConversationId).toBeNull();
- });
-
- it("returns persisted state when provided", () => {
- const persisted: TabsState = {
- tabs: [tab("c1")],
- activeConversationId: "c1",
- };
- const state = initialState(persisted);
- expect(state.tabs).toHaveLength(1);
- expect(state.activeConversationId).toBe("c1");
- });
+ it("returns empty draft state when no persisted state", () => {
+ const state = initialState();
+ expect(state.tabs).toEqual([]);
+ expect(state.activeConversationId).toBeNull();
+ });
+
+ it("returns persisted state when provided", () => {
+ const persisted: TabsState = {
+ tabs: [tab("c1")],
+ activeConversationId: "c1",
+ };
+ const state = initialState(persisted);
+ expect(state.tabs).toHaveLength(1);
+ expect(state.activeConversationId).toBe("c1");
+ });
});
describe("newDraft", () => {
- it("sets activeConversationId to null", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = newDraft(state);
- expect(next.activeConversationId).toBeNull();
- });
-
- it("keeps existing tabs", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = newDraft(state);
- expect(next.tabs).toHaveLength(2);
- });
+ it("sets activeConversationId to null", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = newDraft(state);
+ expect(next.activeConversationId).toBeNull();
+ });
+
+ it("keeps existing tabs", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = newDraft(state);
+ expect(next.tabs).toHaveLength(2);
+ });
});
describe("createTab", () => {
- it("appends and activates", () => {
- const state = initialState();
- const next = createTab(state, tab("c1"));
- expect(next.tabs).toHaveLength(1);
- expect(next.tabs[0]?.conversationId).toBe("c1");
- expect(next.activeConversationId).toBe("c1");
- });
-
- it("does not duplicate an existing conversationId", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = createTab(state, tab("c1"));
- expect(next.tabs).toHaveLength(1);
- });
-
- it("activates an already-existing tab when createTab is called again", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
- const next = createTab(state, tab("c1"));
- expect(next.activeConversationId).toBe("c1");
- });
+ it("appends and activates", () => {
+ const state = initialState();
+ const next = createTab(state, tab("c1"));
+ expect(next.tabs).toHaveLength(1);
+ expect(next.tabs[0]?.conversationId).toBe("c1");
+ expect(next.activeConversationId).toBe("c1");
+ });
+
+ it("does not duplicate an existing conversationId", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = createTab(state, tab("c1"));
+ expect(next.tabs).toHaveLength(1);
+ });
+
+ it("activates an already-existing tab when createTab is called again", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
+ const next = createTab(state, tab("c1"));
+ expect(next.activeConversationId).toBe("c1");
+ });
});
describe("selectTab", () => {
- it("changes active", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = selectTab(state, "c2");
- expect(next.activeConversationId).toBe("c2");
- });
+ it("changes active", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = selectTab(state, "c2");
+ expect(next.activeConversationId).toBe("c2");
+ });
});
describe("closeTab", () => {
- it("removes the tab", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = closeTab(state, "c2");
- expect(next.tabs).toHaveLength(1);
- expect(next.tabs[0]?.conversationId).toBe("c1");
- });
-
- it("closing the active tab activates a neighbour (previous preferred)", () => {
- const state: TabsState = {
- tabs: [tab("c1"), tab("c2"), tab("c3")],
- activeConversationId: "c2",
- };
- const next = closeTab(state, "c2");
- expect(next.activeConversationId).toBe("c1");
- });
-
- it("closing the first active tab activates the next", () => {
- const state: TabsState = {
- tabs: [tab("c1"), tab("c2"), tab("c3")],
- activeConversationId: "c1",
- };
- const next = closeTab(state, "c1");
- expect(next.activeConversationId).toBe("c2");
- });
-
- it("closing the last tab returns to draft (null active)", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = closeTab(state, "c1");
- expect(next.tabs).toHaveLength(0);
- expect(next.activeConversationId).toBeNull();
- });
-
- it("closing a non-active tab does not change active", () => {
- const state: TabsState = {
- tabs: [tab("c1"), tab("c2"), tab("c3")],
- activeConversationId: "c3",
- };
- const next = closeTab(state, "c1");
- expect(next.activeConversationId).toBe("c3");
- });
-
- it("closing a non-existent tab is a no-op", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
- const next = closeTab(state, "missing");
- expect(next).toEqual(state);
- });
+ it("removes the tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = closeTab(state, "c2");
+ expect(next.tabs).toHaveLength(1);
+ expect(next.tabs[0]?.conversationId).toBe("c1");
+ });
+
+ it("closing the active tab activates a neighbour (previous preferred)", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c2",
+ };
+ const next = closeTab(state, "c2");
+ expect(next.activeConversationId).toBe("c1");
+ });
+
+ it("closing the first active tab activates the next", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c1",
+ };
+ const next = closeTab(state, "c1");
+ expect(next.activeConversationId).toBe("c2");
+ });
+
+ it("closing the last tab returns to draft (null active)", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = closeTab(state, "c1");
+ expect(next.tabs).toHaveLength(0);
+ expect(next.activeConversationId).toBeNull();
+ });
+
+ it("closing a non-active tab does not change active", () => {
+ const state: TabsState = {
+ tabs: [tab("c1"), tab("c2"), tab("c3")],
+ activeConversationId: "c3",
+ };
+ const next = closeTab(state, "c1");
+ expect(next.activeConversationId).toBe("c3");
+ });
+
+ it("closing a non-existent tab is a no-op", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "c1" };
+ const next = closeTab(state, "missing");
+ expect(next).toEqual(state);
+ });
});
describe("setModel", () => {
- it("updates the right tab", () => {
- const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" };
- const next = setModel(state, "c1", "new-model");
- expect(next.tabs[0]?.model).toBe("new-model");
- expect(next.tabs[1]?.model).toBe("default");
- });
+ it("updates the right tab", () => {
+ const state: TabsState = { tabs: [tab("c1", "old"), tab("c2")], activeConversationId: "c1" };
+ const next = setModel(state, "c1", "new-model");
+ expect(next.tabs[0]?.model).toBe("new-model");
+ expect(next.tabs[1]?.model).toBe("default");
+ });
});
describe("setTitle", () => {
- it("updates the right tab", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
- const next = setTitle(state, "c1", "Updated title");
- expect(next.tabs[0]?.title).toBe("Updated title");
- expect(next.tabs[1]?.title).toBe("Chat");
- });
+ it("updates the right tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c1" };
+ const next = setTitle(state, "c1", "Updated title");
+ expect(next.tabs[0]?.title).toBe("Updated title");
+ expect(next.tabs[1]?.title).toBe("Chat");
+ });
});
describe("activeTab", () => {
- it("returns the active tab", () => {
- const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
- expect(activeTab(state)?.conversationId).toBe("c2");
- });
-
- it("returns null when activeConversationId is null", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: null };
- expect(activeTab(state)).toBeNull();
- });
-
- it("returns null when active tab is not found in tabs", () => {
- const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" };
- expect(activeTab(state)).toBeNull();
- });
+ it("returns the active tab", () => {
+ const state: TabsState = { tabs: [tab("c1"), tab("c2")], activeConversationId: "c2" };
+ expect(activeTab(state)?.conversationId).toBe("c2");
+ });
+
+ it("returns null when activeConversationId is null", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: null };
+ expect(activeTab(state)).toBeNull();
+ });
+
+ it("returns null when active tab is not found in tabs", () => {
+ const state: TabsState = { tabs: [tab("c1")], activeConversationId: "missing" };
+ expect(activeTab(state)).toBeNull();
+ });
});
describe("deriveTitle", () => {
- it("truncates long messages with ellipsis", () => {
- const msg = "This is a very long message that should be truncated at some point";
- expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026");
- });
-
- it("returns full message when under max", () => {
- expect(deriveTitle("Short", 40)).toBe("Short");
- });
-
- it("collapses whitespace", () => {
- expect(deriveTitle(" hello world ")).toBe("hello world");
- });
-
- it("falls back to 'New chat' for empty input", () => {
- expect(deriveTitle("")).toBe("New chat");
- expect(deriveTitle(" ")).toBe("New chat");
- });
-
- it("uses default max of ~40 chars", () => {
- const msg = "a".repeat(50);
- const result = deriveTitle(msg);
- expect(result).toBe(`${"a".repeat(40)}\u2026`);
- });
+ it("truncates long messages with ellipsis", () => {
+ const msg = "This is a very long message that should be truncated at some point";
+ expect(deriveTitle(msg, 20)).toBe("This is a very long \u2026");
+ });
+
+ it("returns full message when under max", () => {
+ expect(deriveTitle("Short", 40)).toBe("Short");
+ });
+
+ it("collapses whitespace", () => {
+ expect(deriveTitle(" hello world ")).toBe("hello world");
+ });
+
+ it("falls back to 'New chat' for empty input", () => {
+ expect(deriveTitle("")).toBe("New chat");
+ expect(deriveTitle(" ")).toBe("New chat");
+ });
+
+ it("uses default max of ~40 chars", () => {
+ const msg = "a".repeat(50);
+ const result = deriveTitle(msg);
+ expect(result).toBe(`${"a".repeat(40)}\u2026`);
+ });
});
describe("isStuckToEnd", () => {
- it("is false when the strip does not overflow", () => {
- expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false);
- expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false);
- });
-
- it("is true when overflowing and scrolled to the left", () => {
- expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true);
- });
-
- it("is true when overflowing and scrolled to the middle", () => {
- expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true);
- });
-
- it("is false when overflowing but scrolled fully to the right", () => {
- expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
- });
-
- it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => {
- expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
- });
+ it("is false when the strip does not overflow", () => {
+ expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 500 })).toBe(false);
+ expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 400 })).toBe(false);
+ });
+
+ it("is true when overflowing and scrolled to the left", () => {
+ expect(isStuckToEnd({ scrollLeft: 0, clientWidth: 500, scrollWidth: 1000 })).toBe(true);
+ });
+
+ it("is true when overflowing and scrolled to the middle", () => {
+ expect(isStuckToEnd({ scrollLeft: 250, clientWidth: 500, scrollWidth: 1000 })).toBe(true);
+ });
+
+ it("is false when overflowing but scrolled fully to the right", () => {
+ expect(isStuckToEnd({ scrollLeft: 500, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
+ });
+
+ it("treats a 1px subpixel gap at the end as at-rest (epsilon)", () => {
+ expect(isStuckToEnd({ scrollLeft: 499, clientWidth: 500, scrollWidth: 1000 })).toBe(false);
+ });
});
describe("shortHandle", () => {
- it("uses the minimum length when the id is unique", () => {
- const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]);
- expect(h).toBe("3f9a");
- expect(h.length).toBe(MIN_HANDLE_LENGTH);
- });
-
- it("grows the prefix until unique among open tabs", () => {
- // two ids share the first 5 chars → handle grows to 6 to disambiguate
- expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1");
- expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2");
- });
-
- it("shrinks back to the minimum when the colliding sibling is gone", () => {
- expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd");
- });
-
- it("ignores the id itself when present in the list", () => {
- expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead");
- });
-
- it("returns the whole id when shorter than the minimum length", () => {
- expect(shortHandle("ab", ["ab", "cd"])).toBe("ab");
- });
-
- it("falls back to the full id when one id is a prefix of another", () => {
- // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it
- expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd");
- });
+ it("uses the minimum length when the id is unique", () => {
+ const h = shortHandle("3f9a1b2c-aaaa", ["3f9a1b2c-aaaa", "7c2d-bbbb"]);
+ expect(h).toBe("3f9a");
+ expect(h.length).toBe(MIN_HANDLE_LENGTH);
+ });
+
+ it("grows the prefix until unique among open tabs", () => {
+ // two ids share the first 5 chars → handle grows to 6 to disambiguate
+ expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde1");
+ expect(shortHandle("abcde2-yyyy", ["abcde1-xxxx", "abcde2-yyyy"])).toBe("abcde2");
+ });
+
+ it("shrinks back to the minimum when the colliding sibling is gone", () => {
+ expect(shortHandle("abcde1-xxxx", ["abcde1-xxxx"])).toBe("abcd");
+ });
+
+ it("ignores the id itself when present in the list", () => {
+ expect(shortHandle("deadbeef", ["deadbeef"])).toBe("dead");
+ });
+
+ it("returns the whole id when shorter than the minimum length", () => {
+ expect(shortHandle("ab", ["ab", "cd"])).toBe("ab");
+ });
+
+ it("falls back to the full id when one id is a prefix of another", () => {
+ // "abcd" is a prefix of "abcd1234" → no unique shorter prefix exists for it
+ expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd");
+ });
});
diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts
index 53eda78..bc7e30b 100644
--- a/src/features/tabs/tabs.ts
+++ b/src/features/tabs/tabs.ts
@@ -1,40 +1,40 @@
export interface Tab {
- readonly conversationId: string;
- readonly model: string;
- readonly title: string;
- /** The workspace this tab belongs to (the workspace's URL slug). */
- readonly workspaceId: string;
+ readonly conversationId: string;
+ readonly model: string;
+ readonly title: string;
+ /** The workspace this tab belongs to (the workspace's URL slug). */
+ readonly workspaceId: string;
}
export interface TabsState {
- readonly tabs: readonly Tab[];
- readonly activeConversationId: string | null;
+ readonly tabs: readonly Tab[];
+ readonly activeConversationId: string | null;
}
const DEFAULT_TITLE = "New chat";
const DEFAULT_MAX_TITLE_LENGTH = 40;
export function initialState(persisted?: TabsState): TabsState {
- if (persisted !== undefined) {
- // Migrate tabs persisted before workspaces: assign them to the "default"
- // workspace (the fallback for conversations with no workspace).
- const tabs = persisted.tabs.map((t) => {
- const wid = (t as { workspaceId?: string }).workspaceId;
- return { ...t, workspaceId: wid ?? "default" };
- });
- return { tabs, activeConversationId: persisted.activeConversationId };
- }
- return { tabs: [], activeConversationId: null };
+ if (persisted !== undefined) {
+ // Migrate tabs persisted before workspaces: assign them to the "default"
+ // workspace (the fallback for conversations with no workspace).
+ const tabs = persisted.tabs.map((t) => {
+ const wid = (t as { workspaceId?: string }).workspaceId;
+ return { ...t, workspaceId: wid ?? "default" };
+ });
+ return { tabs, activeConversationId: persisted.activeConversationId };
+ }
+ return { tabs: [], activeConversationId: null };
}
export function newDraft(state: TabsState): TabsState {
- return { ...state, activeConversationId: null };
+ return { ...state, activeConversationId: null };
}
export function createTab(state: TabsState, tab: Tab): TabsState {
- const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
- const tabs = exists ? state.tabs : [...state.tabs, tab];
- return { tabs, activeConversationId: tab.conversationId };
+ const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
+ const tabs = exists ? state.tabs : [...state.tabs, tab];
+ return { tabs, activeConversationId: tab.conversationId };
}
/**
@@ -43,54 +43,54 @@ export function createTab(state: TabsState, tab: Tab): TabsState {
* strip but the user stays on their current tab. No-op if already open.
*/
export function openTab(state: TabsState, tab: Tab): TabsState {
- const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
- if (exists) return state;
- return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId };
+ const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
+ if (exists) return state;
+ return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId };
}
export function selectTab(state: TabsState, conversationId: string): TabsState {
- return { ...state, activeConversationId: conversationId };
+ return { ...state, activeConversationId: conversationId };
}
export function closeTab(state: TabsState, conversationId: string): TabsState {
- const idx = state.tabs.findIndex((t) => t.conversationId === conversationId);
- if (idx === -1) return state;
+ const idx = state.tabs.findIndex((t) => t.conversationId === conversationId);
+ if (idx === -1) return state;
- const tabs = state.tabs.filter((t) => t.conversationId !== conversationId);
+ const tabs = state.tabs.filter((t) => t.conversationId !== conversationId);
- if (state.activeConversationId !== conversationId) {
- return { tabs, activeConversationId: state.activeConversationId };
- }
+ if (state.activeConversationId !== conversationId) {
+ return { tabs, activeConversationId: state.activeConversationId };
+ }
- if (tabs.length === 0) {
- return { tabs, activeConversationId: null };
- }
+ if (tabs.length === 0) {
+ return { tabs, activeConversationId: null };
+ }
- // prefer previous tab, else next
- const neighborIdx = idx > 0 ? idx - 1 : 0;
- const neighbor = tabs[neighborIdx];
- return { tabs, activeConversationId: neighbor?.conversationId ?? null };
+ // prefer previous tab, else next
+ const neighborIdx = idx > 0 ? idx - 1 : 0;
+ const neighbor = tabs[neighborIdx];
+ return { tabs, activeConversationId: neighbor?.conversationId ?? null };
}
export function setModel(state: TabsState, conversationId: string, model: string): TabsState {
- const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
- return { tabs, activeConversationId: state.activeConversationId };
+ const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
+ return { tabs, activeConversationId: state.activeConversationId };
}
export function setTitle(state: TabsState, conversationId: string, title: string): TabsState {
- const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t));
- return { tabs, activeConversationId: state.activeConversationId };
+ const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t));
+ return { tabs, activeConversationId: state.activeConversationId };
}
export function activeTab(state: TabsState): Tab | null {
- if (state.activeConversationId === null) return null;
- return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
+ if (state.activeConversationId === null) return null;
+ return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
}
export interface ScrollMetrics {
- readonly scrollLeft: number;
- readonly clientWidth: number;
- readonly scrollWidth: number;
+ readonly scrollLeft: number;
+ readonly clientWidth: number;
+ readonly scrollWidth: number;
}
const STUCK_EPSILON = 1;
@@ -102,16 +102,16 @@ const STUCK_EPSILON = 1;
* this returns false. Pure: layout measurements in, boolean out.
*/
export function isStuckToEnd(m: ScrollMetrics): boolean {
- const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON;
- const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON;
- return overflows && notAtEnd;
+ const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON;
+ const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON;
+ return overflows && notAtEnd;
}
export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string {
- const trimmed = message.trim().replace(/\s+/g, " ");
- if (trimmed.length === 0) return DEFAULT_TITLE;
- if (trimmed.length <= max) return trimmed;
- return `${trimmed.slice(0, max)}\u2026`;
+ const trimmed = message.trim().replace(/\s+/g, " ");
+ if (trimmed.length === 0) return DEFAULT_TITLE;
+ if (trimmed.length <= max) return trimmed;
+ return `${trimmed.slice(0, max)}\u2026`;
}
/** Minimum length of a tab handle (git-style short id). */
@@ -125,10 +125,10 @@ export const MIN_HANDLE_LENGTH = 4;
* id in, the handle string out. (`allIds` may include `conversationId` itself.)
*/
export function shortHandle(conversationId: string, allIds: readonly string[]): string {
- const others = allIds.filter((id) => id !== conversationId);
- for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) {
- const candidate = conversationId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return conversationId;
+ const others = allIds.filter((id) => id !== conversationId);
+ for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) {
+ const candidate = conversationId.slice(0, len);
+ if (!others.some((id) => id.startsWith(candidate))) return candidate;
+ }
+ return conversationId;
}
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index a46b692..087b28c 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -5,209 +5,209 @@ import type { Tab } from "./tabs";
import TabBar from "./ui/TabBar.svelte";
const sampleTabs: readonly Tab[] = [
- { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
- { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" },
- { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
+ { conversationId: "c1", model: "openai/gpt-4", title: "First", workspaceId: "default" },
+ { conversationId: "c2", model: "anthropic/claude-3", title: "Second", workspaceId: "default" },
+ { conversationId: "c3", model: "google/gemini", title: "Third", workspaceId: "default" },
];
describe("TabBar", () => {
- it("renders one role=tab element per tab showing each title", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- expect(tabs).toHaveLength(sampleTabs.length);
- expect(tabs[0]).toHaveTextContent("First");
- expect(tabs[1]).toHaveTextContent("Second");
- expect(tabs[2]).toHaveTextContent("Third");
- });
-
- it("applies tab-active to the active tab only", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c2",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- expect(tabs[0]).not.toHaveClass("tab-active");
- expect(tabs[1]).toHaveClass("tab-active");
- expect(tabs[2]).not.toHaveClass("tab-active");
- });
-
- it("applies tab-active to New chat button when activeConversationId is null", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: null,
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveClass("tab-active");
- });
-
- it("calls onSelect with the conversationId when a tab is clicked", async () => {
- const onSelect = vi.fn();
- const onClose = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect,
- onClose,
- onNewDraft: vi.fn(),
- },
- });
-
- const tabs = screen.getAllByRole("tab");
- const secondTab = tabs[1];
- if (!secondTab) throw new Error("second tab not found");
- await user.click(secondTab);
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("c2");
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it("calls onClose when the close button is clicked and does not call onSelect", async () => {
- const onSelect = vi.fn();
- const onClose = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect,
- onClose,
- onNewDraft: vi.fn(),
- },
- });
-
- const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
- const firstClose = closeButtons[0];
- if (!firstClose) throw new Error("first close button not found");
- await user.click(firstClose);
-
- expect(onClose).toHaveBeenCalledTimes(1);
- expect(onClose).toHaveBeenCalledWith("c1");
- expect(onSelect).not.toHaveBeenCalled();
- });
-
- it("calls onNewDraft when the New chat button is clicked", async () => {
- const onNewDraft = vi.fn();
- const user = userEvent.setup();
-
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft,
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- await user.click(newChat);
-
- expect(onNewDraft).toHaveBeenCalledTimes(1);
- });
-
- it("the New chat button has the sticky class", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveClass("sticky");
- });
-
- it("shows visible 'New Chat' text when activeConversationId is null", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: null,
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).toHaveTextContent("New Chat");
- });
-
- it("does not show 'New Chat' text when a real tab is active", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- const newChat = screen.getByRole("button", { name: "New chat" });
- expect(newChat).not.toHaveTextContent("New Chat");
- });
-
- it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
- const tabs: readonly Tab[] = [
- { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
- { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
- ];
- render(TabBar, {
- props: {
- tabs,
- activeConversationId: "3f9a1b2c-1111",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- expect(screen.getByText("3f9a")).toBeInTheDocument();
- expect(screen.getByText("7c2d")).toBeInTheDocument();
- });
-
- it("renders fixed-width tabs", () => {
- render(TabBar, {
- props: {
- tabs: sampleTabs,
- activeConversationId: "c1",
- onSelect: vi.fn(),
- onClose: vi.fn(),
- onNewDraft: vi.fn(),
- },
- });
-
- for (const t of screen.getAllByRole("tab")) {
- expect(t).toHaveClass("w-48");
- }
- });
+ it("renders one role=tab element per tab showing each title", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(sampleTabs.length);
+ expect(tabs[0]).toHaveTextContent("First");
+ expect(tabs[1]).toHaveTextContent("Second");
+ expect(tabs[2]).toHaveTextContent("Third");
+ });
+
+ it("applies tab-active to the active tab only", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c2",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs[0]).not.toHaveClass("tab-active");
+ expect(tabs[1]).toHaveClass("tab-active");
+ expect(tabs[2]).not.toHaveClass("tab-active");
+ });
+
+ it("applies tab-active to New chat button when activeConversationId is null", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: null,
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).toHaveClass("tab-active");
+ });
+
+ it("calls onSelect with the conversationId when a tab is clicked", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const tabs = screen.getAllByRole("tab");
+ const secondTab = tabs[1];
+ if (!secondTab) throw new Error("second tab not found");
+ await user.click(secondTab);
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("c2");
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it("calls onClose when the close button is clicked and does not call onSelect", async () => {
+ const onSelect = vi.fn();
+ const onClose = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect,
+ onClose,
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const closeButtons = screen.getAllByRole("button", { name: "Close tab" });
+ const firstClose = closeButtons[0];
+ if (!firstClose) throw new Error("first close button not found");
+ await user.click(firstClose);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ expect(onClose).toHaveBeenCalledWith("c1");
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("calls onNewDraft when the New chat button is clicked", async () => {
+ const onNewDraft = vi.fn();
+ const user = userEvent.setup();
+
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft,
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ await user.click(newChat);
+
+ expect(onNewDraft).toHaveBeenCalledTimes(1);
+ });
+
+ it("the New chat button has the sticky class", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).toHaveClass("sticky");
+ });
+
+ it("shows visible 'New Chat' text when activeConversationId is null", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: null,
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).toHaveTextContent("New Chat");
+ });
+
+ it("does not show 'New Chat' text when a real tab is active", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ const newChat = screen.getByRole("button", { name: "New chat" });
+ expect(newChat).not.toHaveTextContent("New Chat");
+ });
+
+ it("renders a short-handle tab ID badge (shortest unique prefix) per tab", () => {
+ const tabs: readonly Tab[] = [
+ { conversationId: "3f9a1b2c-1111", model: "m", title: "Alpha", workspaceId: "default" },
+ { conversationId: "7c2db4e5-2222", model: "m", title: "Beta", workspaceId: "default" },
+ ];
+ render(TabBar, {
+ props: {
+ tabs,
+ activeConversationId: "3f9a1b2c-1111",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ expect(screen.getByText("3f9a")).toBeInTheDocument();
+ expect(screen.getByText("7c2d")).toBeInTheDocument();
+ });
+
+ it("renders fixed-width tabs", () => {
+ render(TabBar, {
+ props: {
+ tabs: sampleTabs,
+ activeConversationId: "c1",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+
+ for (const t of screen.getAllByRole("tab")) {
+ expect(t).toHaveClass("w-48");
+ }
+ });
});
diff --git a/src/features/tabs/ui/TabBar.svelte b/src/features/tabs/ui/TabBar.svelte
index 7ec2101..211fd5c 100644
--- a/src/features/tabs/ui/TabBar.svelte
+++ b/src/features/tabs/ui/TabBar.svelte
@@ -1,178 +1,177 @@
<script lang="ts">
- import type { Tab } from "../tabs";
- import { isStuckToEnd, shortHandle } from "../tabs";
+ import type { Tab } from "../tabs";
+ import { isStuckToEnd, shortHandle } from "../tabs";
- let {
- tabs,
- activeConversationId,
- statusFor,
- onSelect,
- onClose,
- onNewDraft,
- onRename,
- }: {
- tabs: readonly Tab[];
- activeConversationId: string | null;
- /** Returns the conversation's lifecycle status, or undefined when unknown. */
- statusFor?: (conversationId: string) => string | undefined;
- onSelect: (conversationId: string) => void;
- onClose: (conversationId: string) => void;
- onNewDraft: () => void;
- onRename?: (conversationId: string, title: string) => void;
- } = $props();
+ let {
+ tabs,
+ activeConversationId,
+ statusFor,
+ onSelect,
+ onClose,
+ onNewDraft,
+ onRename,
+ }: {
+ tabs: readonly Tab[];
+ activeConversationId: string | null;
+ /** Returns the conversation's lifecycle status, or undefined when unknown. */
+ statusFor?: (conversationId: string) => string | undefined;
+ onSelect: (conversationId: string) => void;
+ onClose: (conversationId: string) => void;
+ onNewDraft: () => void;
+ onRename?: (conversationId: string, title: string) => void;
+ } = $props();
- // The new-chat button is `position: sticky; right: 0`. It floats over the tabs
- // only while the strip overflows and isn't scrolled fully right; we square its
- // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) +
- // DOM-measurement at the edge here.
- let scrollEl = $state<HTMLDivElement>();
- let stuck = $state(false);
+ // The new-chat button is `position: sticky; right: 0`. It floats over the tabs
+ // only while the strip overflows and isn't scrolled fully right; we square its
+ // right edge only in that "stuck" state. Pure decision (`isStuckToEnd`) +
+ // DOM-measurement at the edge here.
+ let scrollEl = $state<HTMLDivElement>();
+ let stuck = $state(false);
- // Git-style short handle (shortest unique prefix) per open tab — the visible
- // "tab ID". Derived from the set of open conversation ids; pure helper.
- const handles = $derived.by(() => {
- const ids = tabs.map((t) => t.conversationId);
- const map = new Map<string, string>();
- for (const id of ids) map.set(id, shortHandle(id, ids));
- return map;
- });
+ // Git-style short handle (shortest unique prefix) per open tab — the visible
+ // "tab ID". Derived from the set of open conversation ids; pure helper.
+ const handles = $derived.by(() => {
+ const ids = tabs.map((t) => t.conversationId);
+ const map = new Map<string, string>();
+ for (const id of ids) map.set(id, shortHandle(id, ids));
+ return map;
+ });
- function recompute(): void {
- const el = scrollEl;
- if (el === undefined) {
- stuck = false;
- return;
- }
- stuck = isStuckToEnd({
- scrollLeft: el.scrollLeft,
- clientWidth: el.clientWidth,
- scrollWidth: el.scrollWidth,
- });
- }
+ function recompute(): void {
+ const el = scrollEl;
+ if (el === undefined) {
+ stuck = false;
+ return;
+ }
+ stuck = isStuckToEnd({
+ scrollLeft: el.scrollLeft,
+ clientWidth: el.clientWidth,
+ scrollWidth: el.scrollWidth,
+ });
+ }
- $effect(() => {
- const el = scrollEl;
- if (el === undefined) return;
- // Re-evaluate when the tab set changes (overflow may appear/disappear).
- void tabs;
- recompute();
+ $effect(() => {
+ const el = scrollEl;
+ if (el === undefined) return;
+ // Re-evaluate when the tab set changes (overflow may appear/disappear).
+ void tabs;
+ recompute();
- el.addEventListener("scroll", recompute, { passive: true });
- const ro =
- typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined;
- ro?.observe(el);
+ el.addEventListener("scroll", recompute, { passive: true });
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(recompute) : undefined;
+ ro?.observe(el);
- return () => {
- el.removeEventListener("scroll", recompute);
- ro?.disconnect();
- };
- });
- // Inline rename: double-click a tab's title to edit, Enter/blur to save.
- let editingId = $state<string | null>(null);
- let editValue = $state("");
- let editEl = $state<HTMLInputElement>();
+ return () => {
+ el.removeEventListener("scroll", recompute);
+ ro?.disconnect();
+ };
+ });
+ // Inline rename: double-click a tab's title to edit, Enter/blur to save.
+ let editingId = $state<string | null>(null);
+ let editValue = $state("");
+ let editEl = $state<HTMLInputElement>();
- function startRename(tab: Tab): void {
- if (onRename === undefined) return;
- editingId = tab.conversationId;
- editValue = tab.title;
- // Focus the input after it renders.
- queueMicrotask(() => editEl?.focus());
- }
+ function startRename(tab: Tab): void {
+ if (onRename === undefined) return;
+ editingId = tab.conversationId;
+ editValue = tab.title;
+ // Focus the input after it renders.
+ queueMicrotask(() => editEl?.focus());
+ }
- function commitRename(): void {
- const id = editingId;
- if (id !== null && onRename !== undefined) {
- const trimmed = editValue.trim();
- if (trimmed.length > 0) onRename(id, trimmed);
- }
- editingId = null;
- }
+ function commitRename(): void {
+ const id = editingId;
+ if (id !== null && onRename !== undefined) {
+ const trimmed = editValue.trim();
+ if (trimmed.length > 0) onRename(id, trimmed);
+ }
+ editingId = null;
+ }
- function cancelRename(): void {
- editingId = null;
- }
+ function cancelRename(): void {
+ editingId = null;
+ }
</script>
<div bind:this={scrollEl} class="min-w-0 flex-1 overflow-x-auto">
- <div class="tabs tabs-lift min-w-max">
- {#each tabs as tab (tab.conversationId)}
- <div
- class="tab flex w-48 shrink-0 items-center gap-1.5"
- class:tab-active={tab.conversationId === activeConversationId}
- role="tab"
- tabindex="0"
- onclick={() => onSelect(tab.conversationId)}
- onkeydown={(e) => {
- if (e.key === "Enter") onSelect(tab.conversationId);
- }}
- >
- <span
- class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
- title="Tab ID"
- >
- {handles.get(tab.conversationId) ?? tab.conversationId}
- </span>
- {#if editingId === tab.conversationId}
- <input
- bind:this={editEl}
- bind:value={editValue}
- class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
- }}
- onblur={commitRename}
- />
- {:else}
- <span
- class="min-w-0 flex-1 cursor-pointer truncate text-left"
- role="button"
- tabindex="-1"
- title={tab.title}
- ondblclick={(e) => {
- e.stopPropagation();
- startRename(tab);
- }}
- >
- {tab.title}
- </span>
- {/if}
- {#if statusFor?.(tab.conversationId) === "active"}
- <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
- {/if}
- <button
- class="btn btn-ghost btn-xs shrink-0"
- aria-label="Close tab"
- onclick={(e) => {
- e.stopPropagation();
- onClose(tab.conversationId);
- }}
- >
- &times;
- </button>
- </div>
- {/each}
- <button
- class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck
- ? '!rounded-se-none !rounded-ee-none'
- : ''}"
- class:tab-active={activeConversationId === null}
- aria-label="New chat"
- onclick={() => onNewDraft()}
- >
- {#if activeConversationId === null}
- <span class="max-w-[120px] truncate">New Chat</span>
- <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span>
- {:else}
- +
- {/if}
- </button>
- </div>
+ <div class="tabs tabs-lift min-w-max">
+ {#each tabs as tab (tab.conversationId)}
+ <div
+ class="tab flex w-48 shrink-0 items-center gap-1.5"
+ class:tab-active={tab.conversationId === activeConversationId}
+ role="tab"
+ tabindex="0"
+ onclick={() => onSelect(tab.conversationId)}
+ onkeydown={(e) => {
+ if (e.key === "Enter") onSelect(tab.conversationId);
+ }}
+ >
+ <span
+ class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60"
+ title="Tab ID"
+ >
+ {handles.get(tab.conversationId) ?? tab.conversationId}
+ </span>
+ {#if editingId === tab.conversationId}
+ <input
+ bind:this={editEl}
+ bind:value={editValue}
+ class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary"
+ onclick={(e) => e.stopPropagation()}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ commitRename();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ cancelRename();
+ }
+ }}
+ onblur={commitRename}
+ />
+ {:else}
+ <span
+ class="min-w-0 flex-1 cursor-pointer truncate text-left"
+ role="button"
+ tabindex="-1"
+ title={tab.title}
+ ondblclick={(e) => {
+ e.stopPropagation();
+ startRename(tab);
+ }}
+ >
+ {tab.title}
+ </span>
+ {/if}
+ {#if statusFor?.(tab.conversationId) === "active"}
+ <span class="loading loading-spinner loading-xs shrink-0 text-primary"></span>
+ {/if}
+ <button
+ class="btn btn-ghost btn-xs shrink-0"
+ aria-label="Close tab"
+ onclick={(e) => {
+ e.stopPropagation();
+ onClose(tab.conversationId);
+ }}
+ >
+ &times;
+ </button>
+ </div>
+ {/each}
+ <button
+ class="tab sticky right-0 z-10 bg-base-200 shadow-[-2px_0_4px_-1px_rgba(0,0,0,0.2)] {stuck
+ ? '!rounded-se-none !rounded-ee-none'
+ : ''}"
+ class:tab-active={activeConversationId === null}
+ aria-label="New chat"
+ onclick={() => onNewDraft()}
+ >
+ {#if activeConversationId === null}
+ <span class="max-w-[120px] truncate">New Chat</span>
+ <span class="btn btn-ghost btn-xs ml-1" aria-hidden="true">+</span>
+ {:else}
+ +
+ {/if}
+ </button>
+ </div>
</div>
diff --git a/src/features/views/index.ts b/src/features/views/index.ts
index c4e7f25..164241d 100644
--- a/src/features/views/index.ts
+++ b/src/features/views/index.ts
@@ -1,15 +1,15 @@
export {
- addPanel,
- initialPanels,
- type PanelsState,
- removePanel,
- selectKind,
- type ViewPanel,
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ type ViewPanel,
} from "./logic/panels";
export { default as ViewSidebar } from "./ui/ViewSidebar.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "views",
- description: "Sidebar view panels (dropdown picker + add / remove)",
+ name: "views",
+ description: "Sidebar view panels (dropdown picker + add / remove)",
} as const;
diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts
index edd7d9e..aadbf5e 100644
--- a/src/features/views/logic/panels.test.ts
+++ b/src/features/views/logic/panels.test.ts
@@ -2,54 +2,54 @@ import { describe, expect, it } from "vitest";
import { addPanel, initialPanels, removePanel, selectKind } from "./panels";
describe("view panels reducer", () => {
- it("seeds one empty panel by default", () => {
- const s = initialPanels();
- expect(s.panels).toHaveLength(1);
- expect(s.panels[0]?.kind).toBeNull();
- });
+ it("seeds one empty panel by default", () => {
+ const s = initialPanels();
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
- it("seeds a panel per provided kind, in order, with unique ids", () => {
- const s = initialPanels(["surfaces", null]);
- expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]);
- expect(new Set(s.panels.map((p) => p.id)).size).toBe(2);
- });
+ it("seeds a panel per provided kind, in order, with unique ids", () => {
+ const s = initialPanels(["surfaces", null]);
+ expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]);
+ expect(new Set(s.panels.map((p) => p.id)).size).toBe(2);
+ });
- it("addPanel appends an empty panel with a fresh id", () => {
- const seed = initialPanels(["surfaces"]);
- const s = addPanel(seed);
- expect(s.panels).toHaveLength(2);
- expect(s.panels[1]?.kind).toBeNull();
- expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id);
- });
+ it("addPanel appends an empty panel with a fresh id", () => {
+ const seed = initialPanels(["surfaces"]);
+ const s = addPanel(seed);
+ expect(s.panels).toHaveLength(2);
+ expect(s.panels[1]?.kind).toBeNull();
+ expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id);
+ });
- it("addPanel can seed a kind", () => {
- const s = addPanel(initialPanels([null]), "surfaces");
- expect(s.panels[1]?.kind).toBe("surfaces");
- });
+ it("addPanel can seed a kind", () => {
+ const s = addPanel(initialPanels([null]), "surfaces");
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
- it("removePanel drops the matching id only", () => {
- const seed = initialPanels(["surfaces", null]);
- const firstId = seed.panels[0]?.id ?? -1;
- const s = removePanel(seed, firstId);
- expect(s.panels).toHaveLength(1);
- expect(s.panels[0]?.kind).toBeNull();
- });
+ it("removePanel drops the matching id only", () => {
+ const seed = initialPanels(["surfaces", null]);
+ const firstId = seed.panels[0]?.id ?? -1;
+ const s = removePanel(seed, firstId);
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
- it("selectKind updates only the targeted panel", () => {
- const seed = initialPanels([null, null]);
- const targetId = seed.panels[1]?.id ?? -1;
- const s = selectKind(seed, targetId, "surfaces");
- expect(s.panels[0]?.kind).toBeNull();
- expect(s.panels[1]?.kind).toBe("surfaces");
- });
+ it("selectKind updates only the targeted panel", () => {
+ const seed = initialPanels([null, null]);
+ const targetId = seed.panels[1]?.id ?? -1;
+ const s = selectKind(seed, targetId, "surfaces");
+ expect(s.panels[0]?.kind).toBeNull();
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
- it("is pure — never mutates the input state", () => {
- const seed = initialPanels(["surfaces"]);
- const snapshot = JSON.stringify(seed);
- const id = seed.panels[0]?.id ?? -1;
- addPanel(seed);
- removePanel(seed, id);
- selectKind(seed, id, null);
- expect(JSON.stringify(seed)).toBe(snapshot);
- });
+ it("is pure — never mutates the input state", () => {
+ const seed = initialPanels(["surfaces"]);
+ const snapshot = JSON.stringify(seed);
+ const id = seed.panels[0]?.id ?? -1;
+ addPanel(seed);
+ removePanel(seed, id);
+ selectKind(seed, id, null);
+ expect(JSON.stringify(seed)).toBe(snapshot);
+ });
});
diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts
index 38c28fb..fa22be6 100644
--- a/src/features/views/logic/panels.ts
+++ b/src/features/views/logic/panels.ts
@@ -10,14 +10,14 @@
*/
export interface ViewPanel {
- readonly id: number;
- /** Selected view-kind id, or `null` while the panel still reads "Select a view". */
- readonly kind: string | null;
+ readonly id: number;
+ /** Selected view-kind id, or `null` while the panel still reads "Select a view". */
+ readonly kind: string | null;
}
export interface PanelsState {
- readonly panels: readonly ViewPanel[];
- readonly nextId: number;
+ readonly panels: readonly ViewPanel[];
+ readonly nextId: number;
}
/**
@@ -25,25 +25,25 @@ export interface PanelsState {
* a single preset panel, or `[null]` for one empty "Select a view" panel.
*/
export function initialPanels(kinds: readonly (string | null)[] = [null]): PanelsState {
- let nextId = 0;
- const panels = kinds.map((kind) => ({ id: nextId++, kind }));
- return { panels, nextId };
+ let nextId = 0;
+ const panels = kinds.map((kind) => ({ id: nextId++, kind }));
+ return { panels, nextId };
}
export function addPanel(state: PanelsState, kind: string | null = null): PanelsState {
- return {
- panels: [...state.panels, { id: state.nextId, kind }],
- nextId: state.nextId + 1,
- };
+ return {
+ panels: [...state.panels, { id: state.nextId, kind }],
+ nextId: state.nextId + 1,
+ };
}
export function removePanel(state: PanelsState, id: number): PanelsState {
- return { ...state, panels: state.panels.filter((p) => p.id !== id) };
+ return { ...state, panels: state.panels.filter((p) => p.id !== id) };
}
export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState {
- return {
- ...state,
- panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)),
- };
+ return {
+ ...state,
+ panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)),
+ };
}
diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte
index e4a3ee6..e9e8682 100644
--- a/src/features/views/ui/ViewSidebar.svelte
+++ b/src/features/views/ui/ViewSidebar.svelte
@@ -1,97 +1,95 @@
<script lang="ts">
- import { type Snippet, untrack } from "svelte";
- import {
- addPanel,
- initialPanels,
- type PanelsState,
- removePanel,
- selectKind,
- } from "../logic/panels";
+ import { type Snippet, untrack } from "svelte";
+ import {
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ } from "../logic/panels";
- interface ViewKind {
- readonly id: string;
- readonly label: string;
- }
+ interface ViewKind {
+ readonly id: string;
+ readonly label: string;
+ }
- let {
- kinds,
- content,
- initial,
- onChange,
- }: {
- /** The view kinds offered in every panel's dropdown. */
- kinds: readonly ViewKind[];
- /** Renders a panel body for the given (non-null) view-kind id. */
- content: Snippet<[string]>;
- /** Optional seed of panel kinds; defaults to one panel of the first kind. */
- initial?: readonly (string | null)[];
- /** Called whenever the panel layout changes (add/remove/select). */
- onChange?: (kinds: readonly (string | null)[]) => void;
- } = $props();
+ let {
+ kinds,
+ content,
+ initial,
+ onChange,
+ }: {
+ /** The view kinds offered in every panel's dropdown. */
+ kinds: readonly ViewKind[];
+ /** Renders a panel body for the given (non-null) view-kind id. */
+ content: Snippet<[string]>;
+ /** Optional seed of panel kinds; defaults to one panel of the first kind. */
+ initial?: readonly (string | null)[];
+ /** Called whenever the panel layout changes (add/remove/select). */
+ onChange?: (kinds: readonly (string | null)[]) => void;
+ } = $props();
- // Local UI composition state, owned by this unit and folded through the pure
- // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
- // the props (untrack makes that one-time read explicit, not reactive).
- let state = $state<PanelsState>(
- untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])),
- );
+ // Local UI composition state, owned by this unit and folded through the pure
+ // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
+ // the props (untrack makes that one-time read explicit, not reactive).
+ let state = $state<PanelsState>(untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])));
- function notify(): void {
- onChange?.(state.panels.map((p) => p.kind));
- }
+ function notify(): void {
+ onChange?.(state.panels.map((p) => p.kind));
+ }
</script>
<div class="flex min-h-0 flex-col gap-2">
- {#each state.panels as panel, idx (panel.id)}
- <div class="flex flex-col rounded-lg bg-base-200 p-3">
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- aria-label="Select a view"
- value={panel.kind ?? ""}
- onchange={(e) => {
- const v = e.currentTarget.value;
- state = selectKind(state, panel.id, v === "" ? null : v);
- notify();
- }}
- >
- <option value="" disabled>Select a view</option>
- {#each kinds as kind (kind.id)}
- <option value={kind.id}>{kind.label}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-square btn-ghost btn-sm shrink-0"
- aria-label="Remove view"
- onclick={() => {
- state = removePanel(state, panel.id);
- notify();
- }}
- >
- ✕
- </button>
- {/if}
- </div>
+ {#each state.panels as panel, idx (panel.id)}
+ <div class="flex flex-col rounded-lg bg-base-200 p-3">
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.kind ?? ""}
+ onchange={(e) => {
+ const v = e.currentTarget.value;
+ state = selectKind(state, panel.id, v === "" ? null : v);
+ notify();
+ }}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each kinds as kind (kind.id)}
+ <option value={kind.id}>{kind.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => {
+ state = removePanel(state, panel.id);
+ notify();
+ }}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
- {#if panel.kind !== null}
- <div class="mt-2">
- {@render content(panel.kind)}
- </div>
- {/if}
- </div>
- {/each}
+ {#if panel.kind !== null}
+ <div class="mt-2">
+ {@render content(panel.kind)}
+ </div>
+ {/if}
+ </div>
+ {/each}
- <button
- type="button"
- class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
- aria-label="Add view"
- onclick={() => {
- state = addPanel(state);
- notify();
- }}
- >
- +
- </button>
+ <button
+ type="button"
+ class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
+ aria-label="Add view"
+ onclick={() => {
+ state = addPanel(state);
+ notify();
+ }}
+ >
+ +
+ </button>
</div>
diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts
index 8a0049c..0618506 100644
--- a/src/features/views/ui/ViewSidebar.test.ts
+++ b/src/features/views/ui/ViewSidebar.test.ts
@@ -5,54 +5,54 @@ import { describe, expect, it } from "vitest";
import ViewSidebar from "./ViewSidebar.svelte";
const kinds = [
- { id: "surfaces", label: "Surfaces" },
- { id: "tasks", label: "Tasks" },
+ { id: "surfaces", label: "Surfaces" },
+ { id: "tasks", label: "Tasks" },
];
// A raw snippet that echoes the kind it was rendered for, so tests can assert
// which view-kind content each panel shows.
const content = createRawSnippet<[string]>((kind) => ({
- render: () => `<div data-testid="view-content">kind:${kind()}</div>`,
+ render: () => `<div data-testid="view-content">kind:${kind()}</div>`,
}));
describe("ViewSidebar", () => {
- it("opens one panel seeded with the first kind and renders its content", () => {
- render(ViewSidebar, { props: { kinds, content } });
- expect(screen.getAllByRole("combobox")).toHaveLength(1);
- expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces");
- });
+ it("opens one panel seeded with the first kind and renders its content", () => {
+ render(ViewSidebar, { props: { kinds, content } });
+ expect(screen.getAllByRole("combobox")).toHaveLength(1);
+ expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces");
+ });
- it("the first panel has no remove button", () => {
- render(ViewSidebar, { props: { kinds, content } });
- expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
- });
+ it("the first panel has no remove button", () => {
+ render(ViewSidebar, { props: { kinds, content } });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ });
- it("the add button appends a new empty panel", async () => {
- const user = userEvent.setup();
- render(ViewSidebar, { props: { kinds, content } });
- await user.click(screen.getByRole("button", { name: "Add view" }));
- expect(screen.getAllByRole("combobox")).toHaveLength(2);
- // the new panel is empty → only the first panel renders content
- expect(screen.getAllByTestId("view-content")).toHaveLength(1);
- });
+ it("the add button appends a new empty panel", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content } });
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(screen.getAllByRole("combobox")).toHaveLength(2);
+ // the new panel is empty → only the first panel renders content
+ expect(screen.getAllByTestId("view-content")).toHaveLength(1);
+ });
- it("non-first panels can be removed", async () => {
- const user = userEvent.setup();
- render(ViewSidebar, { props: { kinds, content } });
- await user.click(screen.getByRole("button", { name: "Add view" }));
- const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
- expect(removeButtons).toHaveLength(1);
- const target = removeButtons[0];
- if (target === undefined) throw new Error("expected a remove button");
- await user.click(target);
- expect(screen.getAllByRole("combobox")).toHaveLength(1);
- });
+ it("non-first panels can be removed", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content } });
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ const target = removeButtons[0];
+ if (target === undefined) throw new Error("expected a remove button");
+ await user.click(target);
+ expect(screen.getAllByRole("combobox")).toHaveLength(1);
+ });
- it("selecting a kind renders that kind's content", async () => {
- const user = userEvent.setup();
- render(ViewSidebar, { props: { kinds, content, initial: [null] } });
- expect(screen.queryByTestId("view-content")).toBeNull();
- await user.selectOptions(screen.getByRole("combobox"), "tasks");
- expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks");
- });
+ it("selecting a kind renders that kind's content", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content, initial: [null] } });
+ expect(screen.queryByTestId("view-content")).toBeNull();
+ await user.selectOptions(screen.getByRole("combobox"), "tasks");
+ expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks");
+ });
});
diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts
index adaff00..19e53f8 100644
--- a/src/features/workspaces/adapter/http.test.ts
+++ b/src/features/workspaces/adapter/http.test.ts
@@ -3,131 +3,131 @@ import { createWorkspaceHttp } from "./http";
/** Build a fake `fetch` returning a canned Response. */
function fakeFetch(responses: Array<{ status?: number; body?: unknown } | Error>): typeof fetch {
- let i = 0;
- return vi.fn(async () => {
- const next = responses[i++];
- if (next === undefined) throw new Error("fakeFetch: no more canned responses");
- if (next instanceof Error) throw next;
- const status = next.status ?? 200;
- const body = next.body;
- return {
- ok: status >= 200 && status < 300,
- status,
- async json() {
- return body;
- },
- } as Response;
- }) as unknown as typeof fetch;
+ let i = 0;
+ return vi.fn(async () => {
+ const next = responses[i++];
+ if (next === undefined) throw new Error("fakeFetch: no more canned responses");
+ if (next instanceof Error) throw next;
+ const status = next.status ?? 200;
+ const body = next.body;
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ async json() {
+ return body;
+ },
+ } as Response;
+ }) as unknown as typeof fetch;
}
const BASE = "http://x";
describe("createWorkspaceHttp", () => {
- it("list returns the workspaces", async () => {
- const fetchImpl = fakeFetch([
- {
- body: {
- workspaces: [
- {
- id: "a",
- title: "A",
- defaultCwd: null,
- createdAt: 1,
- lastActivityAt: 2,
- conversationCount: 3,
- },
- ],
- },
- },
- ]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const list = await http.list();
- expect(list).toHaveLength(1);
- expect(list[0]?.id).toBe("a");
- expect(list[0]?.conversationCount).toBe(3);
- expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`);
- });
+ it("list returns the workspaces", async () => {
+ const fetchImpl = fakeFetch([
+ {
+ body: {
+ workspaces: [
+ {
+ id: "a",
+ title: "A",
+ defaultCwd: null,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 3,
+ },
+ ],
+ },
+ },
+ ]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const list = await http.list();
+ expect(list).toHaveLength(1);
+ expect(list[0]?.id).toBe("a");
+ expect(list[0]?.conversationCount).toBe(3);
+ expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`);
+ });
- it("list returns [] on a failed response (non-fatal)", async () => {
- const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }]));
- expect(await http.list()).toEqual([]);
- });
+ it("list returns [] on a failed response (non-fatal)", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 500 }]));
+ expect(await http.list()).toEqual([]);
+ });
- it("list returns [] on a network error", async () => {
- const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
- expect(await http.list()).toEqual([]);
- });
+ it("list returns [] on a network error", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")]));
+ expect(await http.list()).toEqual([]);
+ });
- it("ensure PUTs the id + returns the workspace", async () => {
- const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 };
- const fetchImpl = fakeFetch([{ body: ws }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const result = await http.ensure("my-ws");
- expect(result).toEqual({ ok: true, value: ws });
- expect(fetchImpl).toHaveBeenCalledWith(
- `${BASE}/workspaces/my-ws`,
- expect.objectContaining({ method: "PUT" }),
- );
- });
+ it("ensure PUTs the id + returns the workspace", async () => {
+ const ws = { id: "my-ws", title: "my-ws", defaultCwd: null, createdAt: 10, lastActivityAt: 10 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.ensure("my-ws");
+ expect(result).toEqual({ ok: true, value: ws });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${BASE}/workspaces/my-ws`,
+ expect.objectContaining({ method: "PUT" }),
+ );
+ });
- it("ensure surfaces the backend error on a 400 (invalid slug)", async () => {
- const http = createWorkspaceHttp(
- BASE,
- fakeFetch([{ status: 400, body: { error: "invalid slug" } }]),
- );
- const result = await http.ensure("UPPER");
- expect(result).toEqual({ ok: false, error: "invalid slug" });
- });
+ it("ensure surfaces the backend error on a 400 (invalid slug)", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 400, body: { error: "invalid slug" } }]),
+ );
+ const result = await http.ensure("UPPER");
+ expect(result).toEqual({ ok: false, error: "invalid slug" });
+ });
- it("get returns null on 404", async () => {
- const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
- expect(await http.get("nope")).toBeNull();
- });
+ it("get returns null on 404", async () => {
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }]));
+ expect(await http.get("nope")).toBeNull();
+ });
- it("get returns the workspace on 200", async () => {
- const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
- const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
- expect(await http.get("x")).toEqual(ws);
- });
+ it("get returns the workspace on 200", async () => {
+ const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 };
+ const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }]));
+ expect(await http.get("x")).toEqual(ws);
+ });
- it("setTitle PUTs the title", async () => {
- const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
- const fetchImpl = fakeFetch([{ body: ws }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const result = await http.setTitle("a", "Renamed");
- expect(result).toEqual({ ok: true, value: ws });
- const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
- expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`);
- expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" });
- });
+ it("setTitle PUTs the title", async () => {
+ const ws = { id: "a", title: "Renamed", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.setTitle("a", "Renamed");
+ expect(result).toEqual({ ok: true, value: ws });
+ const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
+ expect(call?.[0]).toBe(`${BASE}/workspaces/a/title`);
+ expect(JSON.parse(call?.[1]?.body)).toEqual({ title: "Renamed" });
+ });
- it("setDefaultCwd PUTs null to clear", async () => {
- const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
- const fetchImpl = fakeFetch([{ body: ws }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- await http.setDefaultCwd("a", null);
- const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
- expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`);
- expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null });
- });
+ it("setDefaultCwd PUTs null to clear", async () => {
+ const ws = { id: "a", title: "A", defaultCwd: null, createdAt: 1, lastActivityAt: 2 };
+ const fetchImpl = fakeFetch([{ body: ws }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ await http.setDefaultCwd("a", null);
+ const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
+ expect(call?.[0]).toBe(`${BASE}/workspaces/a/default-cwd`);
+ expect(JSON.parse(call?.[1]?.body)).toEqual({ defaultCwd: null });
+ });
- it("delete returns closedCount", async () => {
- const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]);
- const http = createWorkspaceHttp(BASE, fetchImpl);
- const result = await http.delete("a");
- expect(result).toEqual({ ok: true, value: { closedCount: 4 } });
- expect(fetchImpl).toHaveBeenCalledWith(
- `${BASE}/workspaces/a`,
- expect.objectContaining({ method: "DELETE" }),
- );
- });
+ it("delete returns closedCount", async () => {
+ const fetchImpl = fakeFetch([{ body: { workspaceId: "a", closedCount: 4 } }]);
+ const http = createWorkspaceHttp(BASE, fetchImpl);
+ const result = await http.delete("a");
+ expect(result).toEqual({ ok: true, value: { closedCount: 4 } });
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${BASE}/workspaces/a`,
+ expect.objectContaining({ method: "DELETE" }),
+ );
+ });
- it("delete surfaces 409 for 'default'", async () => {
- const http = createWorkspaceHttp(
- BASE,
- fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]),
- );
- const result = await http.delete("default");
- expect(result).toEqual({ ok: false, error: "cannot delete default" });
- });
+ it("delete surfaces 409 for 'default'", async () => {
+ const http = createWorkspaceHttp(
+ BASE,
+ fakeFetch([{ status: 409, body: { error: "cannot delete default" } }]),
+ );
+ const result = await http.delete("default");
+ expect(result).toEqual({ ok: false, error: "cannot delete default" });
+ });
});
diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts
index 6ebfee1..01fe677 100644
--- a/src/features/workspaces/adapter/http.ts
+++ b/src/features/workspaces/adapter/http.ts
@@ -1,13 +1,13 @@
import type {
- DeleteWorkspaceResponse,
- EnsureWorkspaceRequest,
- SetWorkspaceDefaultComputerRequest,
- SetWorkspaceDefaultCwdRequest,
- SetWorkspaceTitleRequest,
- Workspace,
- WorkspaceEntry,
- WorkspaceListResponse,
- WorkspaceResponse,
+ DeleteWorkspaceResponse,
+ EnsureWorkspaceRequest,
+ SetWorkspaceDefaultComputerRequest,
+ SetWorkspaceDefaultCwdRequest,
+ SetWorkspaceTitleRequest,
+ Workspace,
+ WorkspaceEntry,
+ WorkspaceListResponse,
+ WorkspaceResponse,
} from "@dispatch/transport-contract";
/**
@@ -28,130 +28,130 @@ import type {
* - `DELETE /workspaces/:id` (409 for "default") → delete
*/
export type WorkspaceResult<T> =
- | { readonly ok: true; readonly value: T }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly value: T }
+ | { readonly ok: false; readonly error: string };
export interface WorkspaceHttp {
- list(): Promise<readonly WorkspaceEntry[]>;
- ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- get(id: string): Promise<Workspace | null>;
- setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
- setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
- setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
- delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+ list(): Promise<readonly WorkspaceEntry[]>;
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ get(id: string): Promise<Workspace | null>;
+ setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
+ delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
async function errText(res: Response): Promise<string> {
- try {
- const body = (await res.json()) as { error?: string };
- return body.error ?? `HTTP ${res.status}`;
- } catch {
- return `HTTP ${res.status}`;
- }
+ try {
+ const body = (await res.json()) as { error?: string };
+ return body.error ?? `HTTP ${res.status}`;
+ } catch {
+ return `HTTP ${res.status}`;
+ }
}
export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp {
- return {
- async list(): Promise<readonly WorkspaceEntry[]> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces`);
- if (!res.ok) return [];
- const data = (await res.json()) as WorkspaceListResponse;
- return data.workspaces;
- } catch {
- return [];
- }
- },
+ return {
+ async list(): Promise<readonly WorkspaceEntry[]> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces`);
+ if (!res.ok) return [];
+ const data = (await res.json()) as WorkspaceListResponse;
+ return data.workspaces;
+ } catch {
+ return [];
+ }
+ },
- async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body ?? {}),
- });
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Workspace request failed",
- };
- }
- },
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body ?? {}),
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Workspace request failed",
+ };
+ }
+ },
- async get(id): Promise<Workspace | null> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`);
- if (res.status === 404 || !res.ok) return null;
- return (await res.json()) as WorkspaceResponse;
- } catch {
- return null;
- }
- },
+ async get(id): Promise<Workspace | null> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`);
+ if (res.status === 404 || !res.ok) return null;
+ return (await res.json()) as WorkspaceResponse;
+ } catch {
+ return null;
+ }
+ },
- async setTitle(id, title): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest),
- });
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Rename failed" };
- }
- },
+ async setTitle(id, title): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/title`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title } satisfies SetWorkspaceTitleRequest),
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Rename failed" };
+ }
+ },
- async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest),
- },
- );
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" };
- }
- },
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(id)}/default-cwd`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ defaultCwd } satisfies SetWorkspaceDefaultCwdRequest),
+ },
+ );
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Set default cwd failed" };
+ }
+ },
- async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
- try {
- const res = await fetchImpl(
- `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest),
- },
- );
- if (!res.ok) return { ok: false, error: await errText(res) };
- return { ok: true, value: (await res.json()) as WorkspaceResponse };
- } catch (err) {
- return {
- ok: false,
- error: err instanceof Error ? err.message : "Set default computer failed",
- };
- }
- },
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(id)}/default-computer`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId } satisfies SetWorkspaceDefaultComputerRequest),
+ },
+ );
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ return { ok: true, value: (await res.json()) as WorkspaceResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set default computer failed",
+ };
+ }
+ },
- async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
- try {
- const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
- method: "DELETE",
- });
- if (!res.ok) return { ok: false, error: await errText(res) };
- const data = (await res.json()) as DeleteWorkspaceResponse;
- return { ok: true, value: { closedCount: data.closedCount } };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : "Delete failed" };
- }
- },
- };
+ async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) return { ok: false, error: await errText(res) };
+ const data = (await res.json()) as DeleteWorkspaceResponse;
+ return { ok: true, value: { closedCount: data.closedCount } };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Delete failed" };
+ }
+ },
+ };
}
diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts
index 6cc86a3..177e49b 100644
--- a/src/features/workspaces/index.ts
+++ b/src/features/workspaces/index.ts
@@ -2,11 +2,11 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
export { createWorkspaceHttp } from "./adapter/http";
export type { Route } from "./logic/route";
export {
- DEFAULT_WORKSPACE_ID,
- isValidSlug,
- parsePath,
- WORKSPACE_SLUG_RE,
- workspacePath,
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
} from "./logic/route";
export { pageTitle, relativeTime } from "./logic/view-model";
export type { WorkspaceStore } from "./store.svelte";
@@ -16,6 +16,6 @@ export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "workspaces",
- description: "URL-driven conversation grouping with a backend-owned default cwd",
+ name: "workspaces",
+ description: "URL-driven conversation grouping with a backend-owned default cwd",
} as const;
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts
index a6da3e1..96e0ff3 100644
--- a/src/features/workspaces/logic/route.test.ts
+++ b/src/features/workspaces/logic/route.test.ts
@@ -1,77 +1,77 @@
import { describe, expect, it } from "vitest";
import {
- DEFAULT_WORKSPACE_ID,
- isValidSlug,
- parsePath,
- WORKSPACE_SLUG_RE,
- workspacePath,
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
} from "./route";
describe("parsePath", () => {
- it("treats the root path as home", () => {
- expect(parsePath("/")).toEqual({ kind: "home" });
- expect(parsePath("")).toEqual({ kind: "home" });
- });
+ it("treats the root path as home", () => {
+ expect(parsePath("/")).toEqual({ kind: "home" });
+ expect(parsePath("")).toEqual({ kind: "home" });
+ });
- it("trims surrounding slashes", () => {
- expect(parsePath("//")).toEqual({ kind: "home" });
- expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" });
- });
+ it("trims surrounding slashes", () => {
+ expect(parsePath("//")).toEqual({ kind: "home" });
+ expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" });
+ });
- it("parses a single segment as a workspace id", () => {
- expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" });
- expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" });
- expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" });
- });
+ it("parses a single segment as a workspace id", () => {
+ expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" });
+ expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" });
+ expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" });
+ });
- it("takes only the first segment of a deeper path", () => {
- expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" });
- expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" });
- });
+ it("takes only the first segment of a deeper path", () => {
+ expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" });
+ expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" });
+ });
- it("URL-decodes the segment", () => {
- expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" });
- });
+ it("URL-decodes the segment", () => {
+ expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" });
+ });
- it("does not validate the slug — an invalid id is still a workspace route", () => {
- expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" });
- expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" });
- });
+ it("does not validate the slug — an invalid id is still a workspace route", () => {
+ expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" });
+ expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" });
+ });
});
describe("isValidSlug", () => {
- it("accepts lowercase alphanumeric + internal hyphens", () => {
- expect(isValidSlug("default")).toBe(true);
- expect(isValidSlug("my-workspace")).toBe(true);
- expect(isValidSlug("a")).toBe(true);
- expect(isValidSlug("ws-1")).toBe(true);
- });
+ it("accepts lowercase alphanumeric + internal hyphens", () => {
+ expect(isValidSlug("default")).toBe(true);
+ expect(isValidSlug("my-workspace")).toBe(true);
+ expect(isValidSlug("a")).toBe(true);
+ expect(isValidSlug("ws-1")).toBe(true);
+ });
- it("accepts up to 40 chars", () => {
- expect(isValidSlug("a".repeat(40))).toBe(true);
- });
+ it("accepts up to 40 chars", () => {
+ expect(isValidSlug("a".repeat(40))).toBe(true);
+ });
- it("rejects empty and too-long", () => {
- expect(isValidSlug("")).toBe(false);
- expect(isValidSlug("a".repeat(41))).toBe(false);
- });
+ it("rejects empty and too-long", () => {
+ expect(isValidSlug("")).toBe(false);
+ expect(isValidSlug("a".repeat(41))).toBe(false);
+ });
- it("rejects uppercase, spaces, and leading/trailing hyphens", () => {
- expect(isValidSlug("MyWS")).toBe(false);
- expect(isValidSlug("has space")).toBe(false);
- expect(isValidSlug("-leading")).toBe(false);
- expect(isValidSlug("trailing-")).toBe(false);
- expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex
- });
+ it("rejects uppercase, spaces, and leading/trailing hyphens", () => {
+ expect(isValidSlug("MyWS")).toBe(false);
+ expect(isValidSlug("has space")).toBe(false);
+ expect(isValidSlug("-leading")).toBe(false);
+ expect(isValidSlug("trailing-")).toBe(false);
+ expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex
+ });
- it("WORKSPACE_SLUG_RE matches the default id", () => {
- expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true);
- });
+ it("WORKSPACE_SLUG_RE matches the default id", () => {
+ expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true);
+ });
});
describe("workspacePath", () => {
- it("builds the URL path for a workspace id", () => {
- expect(workspacePath("default")).toBe("/default");
- expect(workspacePath("my-ws")).toBe("/my-ws");
- });
+ it("builds the URL path for a workspace id", () => {
+ expect(workspacePath("default")).toBe("/default");
+ expect(workspacePath("my-ws")).toBe("/my-ws");
+ });
});
diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts
index e30dc16..015c6d6 100644
--- a/src/features/workspaces/logic/route.ts
+++ b/src/features/workspaces/logic/route.ts
@@ -30,12 +30,12 @@ export const DEFAULT_WORKSPACE_ID = "default";
* surfaces the error).
*/
export function parsePath(pathname: string): Route {
- const trimmed = pathname.replace(/^\/+|\/+$/g, "");
- if (trimmed === "") return { kind: "home" };
- const first = trimmed.split("/")[0] ?? "";
- const id = safeDecode(first);
- if (id === "") return { kind: "home" };
- return { kind: "workspace", id };
+ const trimmed = pathname.replace(/^\/+|\/+$/g, "");
+ if (trimmed === "") return { kind: "home" };
+ const first = trimmed.split("/")[0] ?? "";
+ const id = safeDecode(first);
+ if (id === "") return { kind: "home" };
+ return { kind: "workspace", id };
}
/**
@@ -43,7 +43,7 @@ export function parsePath(pathname: string): Route {
* Used by the home view's "new workspace" input before navigating.
*/
export function isValidSlug(slug: string): boolean {
- return WORKSPACE_SLUG_RE.test(slug);
+ return WORKSPACE_SLUG_RE.test(slug);
}
/**
@@ -51,13 +51,13 @@ export function isValidSlug(slug: string): boolean {
* workspace. Pure: id in, path string out.
*/
export function workspacePath(id: string): string {
- return `/${id}`;
+ return `/${id}`;
}
function safeDecode(segment: string): string {
- try {
- return decodeURIComponent(segment);
- } catch {
- return segment;
- }
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
}
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
index a4ed19c..86342e7 100644
--- a/src/features/workspaces/logic/view-model.test.ts
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -3,66 +3,66 @@ import { describe, expect, it } from "vitest";
import { pageTitle, relativeTime } from "./view-model";
describe("relativeTime", () => {
- const now = 1_000_000_000_000; // 2001-09-09
+ const now = 1_000_000_000_000; // 2001-09-09
- it("is 'now' within a minute", () => {
- expect(relativeTime(now, now)).toBe("now");
- expect(relativeTime(now - 59_000, now)).toBe("now");
- });
+ it("is 'now' within a minute", () => {
+ expect(relativeTime(now, now)).toBe("now");
+ expect(relativeTime(now - 59_000, now)).toBe("now");
+ });
- it("is minutes under an hour", () => {
- expect(relativeTime(now - 5 * 60_000, now)).toBe("5m");
- expect(relativeTime(now - 59 * 60_000, now)).toBe("59m");
- });
+ it("is minutes under an hour", () => {
+ expect(relativeTime(now - 5 * 60_000, now)).toBe("5m");
+ expect(relativeTime(now - 59 * 60_000, now)).toBe("59m");
+ });
- it("is hours under a day", () => {
- expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h");
- });
+ it("is hours under a day", () => {
+ expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h");
+ });
- it("is days under a week", () => {
- expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
- });
+ it("is days under a week", () => {
+ expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
+ });
- it("is a short date beyond a week", () => {
- // 7+ days ago: just check it is a MM/DD string.
- const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
- expect(s).toMatch(/^\d{2}\/\d{2}$/);
- });
+ it("is a short date beyond a week", () => {
+ // 7+ days ago: just check it is a MM/DD string.
+ const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
+ expect(s).toMatch(/^\d{2}\/\d{2}$/);
+ });
});
describe("pageTitle", () => {
- // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
- const ws = (id: string, title: string): WorkspaceEntry => ({
- id,
- title,
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 0,
- lastActivityAt: 0,
- conversationCount: 0,
- });
+ // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
+ const ws = (id: string, title: string): WorkspaceEntry => ({
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
- it("is 'Dispatch' for the home route", () => {
- expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch");
- expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch");
- });
+ it("is 'Dispatch' for the home route", () => {
+ expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch");
+ expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch");
+ });
- it("is 'Dispatch: {title}' for a workspace with a display title", () => {
- const list = [ws("default", "Default"), ws("my-ws", "My Workspace")];
- expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace");
- });
+ it("is 'Dispatch: {title}' for a workspace with a display title", () => {
+ const list = [ws("default", "Default"), ws("my-ws", "My Workspace")];
+ expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace");
+ });
- it("falls back to the slug (id) until the list has loaded the workspace", () => {
- expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending");
- });
+ it("falls back to the slug (id) until the list has loaded the workspace", () => {
+ expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending");
+ });
- it("uses the id as the title when it was never customized (defaults to id)", () => {
- const list = [ws("default", "default")];
- expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default");
- });
+ it("uses the id as the title when it was never customized (defaults to id)", () => {
+ const list = [ws("default", "default")];
+ expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default");
+ });
- it("matches by id, not title", () => {
- const list = [ws("a", "shared-title"), ws("b", "shared-title")];
- expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title");
- });
+ it("matches by id, not title", () => {
+ const list = [ws("a", "shared-title"), ws("b", "shared-title")];
+ expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title");
+ });
});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
index e904514..6dd64c6 100644
--- a/src/features/workspaces/logic/view-model.ts
+++ b/src/features/workspaces/logic/view-model.ts
@@ -14,9 +14,9 @@ import type { Route } from "./route";
* workspaces in, string out.
*/
export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string {
- if (route.kind === "home") return "Dispatch";
- const ws = workspaces.find((w) => w.id === route.id);
- return `Dispatch: ${ws?.title ?? route.id}`;
+ if (route.kind === "home") return "Dispatch";
+ const ws = workspaces.find((w) => w.id === route.id);
+ return `Dispatch: ${ws?.title ?? route.id}`;
}
/**
@@ -25,17 +25,17 @@ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]):
* (a workspace just created) read as "now".
*/
export function relativeTime(then: number, now: number): string {
- const diff = now - then;
- if (diff < 60_000) return "now";
- const mins = Math.floor(diff / 60_000);
- if (mins < 60) return `${mins}m`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h`;
- const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d`;
- // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
- const d = new Date(then);
- const month = String(d.getUTCMonth() + 1).padStart(2, "0");
- const day = String(d.getUTCDate()).padStart(2, "0");
- return `${month}/${day}`;
+ const diff = now - then;
+ if (diff < 60_000) return "now";
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d`;
+ // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
+ const d = new Date(then);
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${month}/${day}`;
}
diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts
index 0cf4ce2..046c235 100644
--- a/src/features/workspaces/store.svelte.ts
+++ b/src/features/workspaces/store.svelte.ts
@@ -9,80 +9,80 @@ import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http";
* owned by the composition root.
*/
export interface WorkspaceStore {
- /** All workspaces (sorted by lastActivityAt desc by the backend). */
- readonly list: readonly WorkspaceEntry[];
- readonly loading: boolean;
- readonly error: string | null;
- /** Refresh the list from the backend. */
- refresh(): Promise<void>;
- /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
- ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
- /** Rename a workspace (display only; id unchanged). */
- rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
- /** Set/clear a workspace's default cwd. */
- setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
- /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
- setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
- /** Delete a workspace (closes its conversations, reassigns to "default"). */
- remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
+ /** All workspaces (sorted by lastActivityAt desc by the backend). */
+ readonly list: readonly WorkspaceEntry[];
+ readonly loading: boolean;
+ readonly error: string | null;
+ /** Refresh the list from the backend. */
+ refresh(): Promise<void>;
+ /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */
+ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>;
+ /** Rename a workspace (display only; id unchanged). */
+ rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default cwd. */
+ setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Set/clear a workspace's default computer (SSH `Host` alias; null = local). */
+ setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>;
+ /** Delete a workspace (closes its conversations, reassigns to "default"). */
+ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>;
}
export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore {
- let list = $state<readonly WorkspaceEntry[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
+ let list = $state<readonly WorkspaceEntry[]>([]);
+ let loading = $state(false);
+ let error = $state<string | null>(null);
- return {
- get list(): readonly WorkspaceEntry[] {
- return list;
- },
- get loading(): boolean {
- return loading;
- },
- get error(): string | null {
- return error;
- },
+ return {
+ get list(): readonly WorkspaceEntry[] {
+ return list;
+ },
+ get loading(): boolean {
+ return loading;
+ },
+ get error(): string | null {
+ return error;
+ },
- async refresh(): Promise<void> {
- loading = true;
- error = null;
- try {
- list = await http.list();
- } catch (err) {
- error = err instanceof Error ? err.message : "Failed to load workspaces";
- } finally {
- loading = false;
- }
- },
+ async refresh(): Promise<void> {
+ loading = true;
+ error = null;
+ try {
+ list = await http.list();
+ } catch (err) {
+ error = err instanceof Error ? err.message : "Failed to load workspaces";
+ } finally {
+ loading = false;
+ }
+ },
- async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
- const result = await http.ensure(id, body);
- if (result.ok) void this.refresh();
- return result;
- },
+ async ensure(id, body): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.ensure(id, body);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async rename(id, title): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setTitle(id, title);
- if (result.ok) void this.refresh();
- return result;
- },
+ async rename(id, title): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setTitle(id, title);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setDefaultCwd(id, defaultCwd);
- if (result.ok) void this.refresh();
- return result;
- },
+ async setDefaultCwd(id, defaultCwd): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultCwd(id, defaultCwd);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
- const result = await http.setDefaultComputer(id, computerId);
- if (result.ok) void this.refresh();
- return result;
- },
+ async setDefaultComputer(id, computerId): Promise<WorkspaceResult<Workspace>> {
+ const result = await http.setDefaultComputer(id, computerId);
+ if (result.ok) void this.refresh();
+ return result;
+ },
- async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
- const result = await http.delete(id);
- if (result.ok) void this.refresh();
- return result;
- },
- };
+ async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> {
+ const result = await http.delete(id);
+ if (result.ok) void this.refresh();
+ return result;
+ },
+ };
}
diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte
index ff8a8ea..81b89e9 100644
--- a/src/features/workspaces/ui/WorkspaceCard.svelte
+++ b/src/features/workspaces/ui/WorkspaceCard.svelte
@@ -1,212 +1,205 @@
<script lang="ts">
- import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
- import { untrack } from "svelte";
- import type { WorkspaceStore } from "../store.svelte";
- import { relativeTime } from "../logic/view-model";
- import { workspacePath } from "../logic/route";
- import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
-
- let {
- ws,
- store,
- onNavigate,
- computers,
- }: {
- ws: WorkspaceEntry;
- store: WorkspaceStore;
- onNavigate: (path: string) => void;
- /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
- computers: readonly ComputerEntry[];
- } = $props();
-
- // ── Title: double-click to rename inline ──────────────────────────────────
- let editingTitle = $state(false);
- let titleDraft = $state("");
- let titleInput = $state<HTMLInputElement | undefined>();
- let titleError = $state<string | null>(null);
-
- function startEditTitle(): void {
- titleDraft = ws.title;
- titleError = null;
- editingTitle = true;
- queueMicrotask(() => titleInput?.focus());
- }
-
- async function saveTitle(): Promise<void> {
- if (!editingTitle) return;
- const title = titleDraft.trim();
- editingTitle = false;
- if (title === "" || title === ws.title) return;
- const result = await store.rename(ws.id, title);
- if (!result.ok) titleError = result.error;
- }
-
- function cancelTitle(): void {
- editingTitle = false;
- titleError = null;
- }
-
- // ── Default cwd: inline input ─────────────────────────────────────────────
- let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
- // Reseed when the backend value changes (e.g., after a save or an external
- // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
- $effect(() => {
- cwdDraft = ws.defaultCwd ?? "";
- });
-
- const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
- let savingCwd = $state(false);
- let cwdError = $state<string | null>(null);
-
- async function saveCwd(): Promise<void> {
- if (!cwdDirty || savingCwd) return;
- savingCwd = true;
- cwdError = null;
- const cwd = cwdDraft.trim();
- const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
- savingCwd = false;
- if (!result.ok) cwdError = result.error;
- }
-
- // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
- let savingComputer = $state(false);
- let computerError = $state<string | null>(null);
-
- async function saveComputer(computerId: string | null): Promise<void> {
- if (savingComputer) return;
- // No-op when unchanged (the select only fires on a real change, but guard).
- if (computerId === (ws.defaultComputerId ?? null)) return;
- savingComputer = true;
- computerError = null;
- const result = await store.setDefaultComputer(ws.id, computerId);
- savingComputer = false;
- if (!result.ok) computerError = result.error;
- }
-
- // ── Delete ─────────────────────────────────────────────────────────────────
- let deleting = $state(false);
-
- async function handleDelete(): Promise<void> {
- if (
- !window.confirm(
- `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
- )
- ) {
- return;
- }
- deleting = true;
- await store.remove(ws.id);
- deleting = false;
- }
+ import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire";
+ import { untrack } from "svelte";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { relativeTime } from "../logic/view-model";
+ import { workspacePath } from "../logic/route";
+ import ComputerSelect from "../../computer/ui/ComputerSelect.svelte";
+
+ let {
+ ws,
+ store,
+ onNavigate,
+ computers,
+ }: {
+ ws: WorkspaceEntry;
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ /** Discovered computers (`GET /computers`), for the default-computer dropdown. */
+ computers: readonly ComputerEntry[];
+ } = $props();
+
+ // ── Title: double-click to rename inline ──────────────────────────────────
+ let editingTitle = $state(false);
+ let titleDraft = $state("");
+ let titleInput = $state<HTMLInputElement | undefined>();
+ let titleError = $state<string | null>(null);
+
+ function startEditTitle(): void {
+ titleDraft = ws.title;
+ titleError = null;
+ editingTitle = true;
+ queueMicrotask(() => titleInput?.focus());
+ }
+
+ async function saveTitle(): Promise<void> {
+ if (!editingTitle) return;
+ const title = titleDraft.trim();
+ editingTitle = false;
+ if (title === "" || title === ws.title) return;
+ const result = await store.rename(ws.id, title);
+ if (!result.ok) titleError = result.error;
+ }
+
+ function cancelTitle(): void {
+ editingTitle = false;
+ titleError = null;
+ }
+
+ // ── Default cwd: inline input ─────────────────────────────────────────────
+ let cwdDraft = $state(untrack(() => ws.defaultCwd ?? ""));
+ // Reseed when the backend value changes (e.g., after a save or an external
+ // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered.
+ $effect(() => {
+ cwdDraft = ws.defaultCwd ?? "";
+ });
+
+ const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? ""));
+ let savingCwd = $state(false);
+ let cwdError = $state<string | null>(null);
+
+ async function saveCwd(): Promise<void> {
+ if (!cwdDirty || savingCwd) return;
+ savingCwd = true;
+ cwdError = null;
+ const cwd = cwdDraft.trim();
+ const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd);
+ savingCwd = false;
+ if (!result.ok) cwdError = result.error;
+ }
+
+ // ── Default computer: dropdown (Local / discovered SSH aliases) ────────────
+ let savingComputer = $state(false);
+ let computerError = $state<string | null>(null);
+
+ async function saveComputer(computerId: string | null): Promise<void> {
+ if (savingComputer) return;
+ // No-op when unchanged (the select only fires on a real change, but guard).
+ if (computerId === (ws.defaultComputerId ?? null)) return;
+ savingComputer = true;
+ computerError = null;
+ const result = await store.setDefaultComputer(ws.id, computerId);
+ savingComputer = false;
+ if (!result.ok) computerError = result.error;
+ }
+
+ // ── Delete ─────────────────────────────────────────────────────────────────
+ let deleting = $state(false);
+
+ async function handleDelete(): Promise<void> {
+ if (
+ !window.confirm(
+ `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`,
+ )
+ ) {
+ return;
+ }
+ deleting = true;
+ await store.remove(ws.id);
+ deleting = false;
+ }
</script>
<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3">
- <div class="flex items-center gap-2">
- {#if editingTitle}
- <input
- bind:this={titleInput}
- bind:value={titleDraft}
- class="input input-bordered input-sm flex-1"
- aria-label="Workspace title"
- onkeydown={(e) => {
- if (e.key === "Enter") saveTitle();
- else if (e.key === "Escape") cancelTitle();
- }}
- onblur={saveTitle}
- />
- {:else}
- <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
- <span
- class="flex-1 cursor-default truncate font-semibold"
- title="Double-click to rename"
- ondblclick={startEditTitle}
- >{ws.title}</span>
- {/if}
- <span class="font-mono text-xs opacity-50">/{ws.id}</span>
- <span class="ml-auto text-xs opacity-50">
- {ws.conversationCount}
- {ws.conversationCount === 1 ? "conversation" : "conversations"}
- · {relativeTime(ws.lastActivityAt, Date.now())}
- </span>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={deleting}
- title="Delete workspace"
- aria-label="Delete workspace"
- onclick={handleDelete}
- >
- {#if deleting}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- ✕
- {/if}
- </button>
- </div>
-
- {#if titleError}
- <p class="text-xs text-error">{titleError}</p>
- {/if}
-
- <div class="flex items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
- <input
- type="text"
- class="input input-bordered input-sm flex-1 font-mono text-xs"
- placeholder="inherits the server default"
- bind:value={cwdDraft}
- aria-label="Default working directory"
- onkeydown={(e) => {
- if (e.key === "Enter") saveCwd();
- }}
- />
- <button
- type="button"
- class="btn btn-primary btn-xs"
- disabled={!cwdDirty || savingCwd}
- onclick={saveCwd}
- >
- {#if savingCwd}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
-
- <div class="flex items-center gap-2">
- <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
- <ComputerSelect
- value={ws.defaultComputerId}
- {computers}
- disabled={savingComputer}
- onSelect={saveComputer}
- />
- {#if savingComputer}
- <span class="loading loading-spinner loading-xs shrink-0"></span>
- {/if}
- </div>
-
- <div class="flex justify-start">
- <a
- class="btn"
- href={workspacePath(ws.id)}
- target="_blank"
- rel="noopener noreferrer"
- >
- Open
- </a>
- </div>
-
- {#if cwdError}
- <p class="text-xs text-error">{cwdError}</p>
- {:else if !cwdDirty && !ws.defaultCwd}
- <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
- {/if}
-
- {#if computerError}
- <p class="text-xs text-error">{computerError}</p>
- {:else if !ws.defaultComputerId}
- <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
- {/if}
+ <div class="flex items-center gap-2">
+ {#if editingTitle}
+ <input
+ bind:this={titleInput}
+ bind:value={titleDraft}
+ class="input input-bordered input-sm flex-1"
+ aria-label="Workspace title"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveTitle();
+ else if (e.key === "Escape") cancelTitle();
+ }}
+ onblur={saveTitle}
+ />
+ {:else}
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
+ <span
+ class="flex-1 cursor-default truncate font-semibold"
+ title="Double-click to rename"
+ ondblclick={startEditTitle}>{ws.title}</span
+ >
+ {/if}
+ <span class="font-mono text-xs opacity-50">/{ws.id}</span>
+ <span class="ml-auto text-xs opacity-50">
+ {ws.conversationCount}
+ {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ · {relativeTime(ws.lastActivityAt, Date.now())}
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={deleting}
+ title="Delete workspace"
+ aria-label="Delete workspace"
+ onclick={handleDelete}
+ >
+ {#if deleting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+
+ {#if titleError}
+ <p class="text-xs text-error">{titleError}</p>
+ {/if}
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">cwd</span>
+ <input
+ type="text"
+ class="input input-bordered input-sm flex-1 font-mono text-xs"
+ placeholder="inherits the server default"
+ bind:value={cwdDraft}
+ aria-label="Default working directory"
+ onkeydown={(e) => {
+ if (e.key === "Enter") saveCwd();
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!cwdDirty || savingCwd}
+ onclick={saveCwd}
+ >
+ {#if savingCwd}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+
+ <div class="flex items-center gap-2">
+ <span class="w-8 shrink-0 text-xs opacity-60">ssh</span>
+ <ComputerSelect
+ value={ws.defaultComputerId}
+ {computers}
+ disabled={savingComputer}
+ onSelect={saveComputer}
+ />
+ {#if savingComputer}
+ <span class="loading loading-spinner loading-xs shrink-0"></span>
+ {/if}
+ </div>
+
+ <div class="flex justify-start">
+ <a class="btn" href={workspacePath(ws.id)} target="_blank" rel="noopener noreferrer"> Open </a>
+ </div>
+
+ {#if cwdError}
+ <p class="text-xs text-error">{cwdError}</p>
+ {:else if !cwdDirty && !ws.defaultCwd}
+ <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p>
+ {/if}
+
+ {#if computerError}
+ <p class="text-xs text-error">{computerError}</p>
+ {:else if !ws.defaultComputerId}
+ <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p>
+ {/if}
</li>
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index f6ea432..0736efb 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -7,128 +7,128 @@ import type { WorkspaceStore } from "../store.svelte";
import WorkspaceCard from "./WorkspaceCard.svelte";
function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
- return {
- id: "my-ws",
- title: "My Workspace",
- defaultCwd: null,
- defaultComputerId: null,
- createdAt: 1,
- lastActivityAt: 2,
- conversationCount: 3,
- ...overrides,
- };
+ return {
+ id: "my-ws",
+ title: "My Workspace",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 1,
+ lastActivityAt: 2,
+ conversationCount: 3,
+ ...overrides,
+ };
}
/** A fake store that records calls + resolves ok. */
function fakeStore() {
- return {
- rename: vi.fn(
- async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, title: _title }),
- }),
- ),
- setDefaultCwd: vi.fn(
- async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultCwd }),
- }),
- ),
- setDefaultComputer: vi.fn(
- async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultComputerId: computerId }),
- }),
- ),
- remove: vi.fn(
- async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
- ok: true,
- value: { closedCount: 0 },
- }),
- ),
- };
+ return {
+ rename: vi.fn(
+ async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, title: _title }),
+ }),
+ ),
+ setDefaultCwd: vi.fn(
+ async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultCwd }),
+ }),
+ ),
+ setDefaultComputer: vi.fn(
+ async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
+ ok: true,
+ value: fakeEntry({ id, defaultComputerId: computerId }),
+ }),
+ ),
+ remove: vi.fn(
+ async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
+ ok: true,
+ value: { closedCount: 0 },
+ }),
+ ),
+ };
}
describe("WorkspaceCard", () => {
- it("renders the title, slug, and an Open link", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate, computers: [] },
- });
- expect(screen.getByText("My Workspace")).toBeInTheDocument();
- expect(screen.getByText("/my-ws")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
- });
-
- it("double-clicking the title reveals an edit input", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
- });
-
- it("renames via the store on Enter", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- const input = screen.getByLabelText("Workspace title");
- await user.clear(input);
- await user.type(input, "Renamed{Enter}");
-
- expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
- });
-
- it("enables Set only when the cwd differs, then saves it", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- expect(input).toHaveValue("/old");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, "/new/path");
- expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
-
- await user.click(screen.getByRole("button", { name: "Set" }));
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
- });
-
- it("clears the cwd to null when saved empty (inherits the server default)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- await user.clear(input);
- await user.click(screen.getByRole("button", { name: "Set" }));
-
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
- });
-
- it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const onNavigate = vi.fn();
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
-
- const open = screen.getByRole("link", { name: "Open" });
- // Native new-tab link: href points at the workspace, and target="_blank"
- // opens it in a new browser tab rather than client-side navigating.
- expect(open).toHaveAttribute("href", "/my-ws");
- expect(open).toHaveAttribute("target", "_blank");
- expect(open.getAttribute("rel") ?? "").toMatch(/noopener/);
- });
+ it("renders the title, slug, and an Open link", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+ expect(screen.getByText("My Workspace")).toBeInTheDocument();
+ expect(screen.getByText("/my-ws")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
+ });
+
+ it("double-clicking the title reveals an edit input", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
+ });
+
+ it("renames via the store on Enter", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ await user.dblClick(screen.getByText("My Workspace"));
+ const input = screen.getByLabelText("Workspace title");
+ await user.clear(input);
+ await user.type(input, "Renamed{Enter}");
+
+ expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
+ });
+
+ it("enables Set only when the cwd differs, then saves it", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ expect(input).toHaveValue("/old");
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+
+ await user.clear(input);
+ await user.type(input, "/new/path");
+ expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
+
+ await user.click(screen.getByRole("button", { name: "Set" }));
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
+ });
+
+ it("clears the cwd to null when saved empty (inherits the server default)", async () => {
+ const user = userEvent.setup();
+ const store = fakeStore() as unknown as WorkspaceStore;
+ render(WorkspaceCard, {
+ props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
+ });
+
+ const input = screen.getByLabelText("Default working directory");
+ await user.clear(input);
+ await user.click(screen.getByRole("button", { name: "Set" }));
+
+ expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
+ });
+
+ it("the Open link opens the workspace in a new browser tab (no same-tab navigation)", () => {
+ const store = fakeStore() as unknown as WorkspaceStore;
+ const onNavigate = vi.fn();
+ render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
+
+ const open = screen.getByRole("link", { name: "Open" });
+ // Native new-tab link: href points at the workspace, and target="_blank"
+ // opens it in a new browser tab rather than client-side navigating.
+ expect(open).toHaveAttribute("href", "/my-ws");
+ expect(open).toHaveAttribute("target", "_blank");
+ expect(open.getAttribute("rel") ?? "").toMatch(/noopener/);
+ });
});
diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte
index bdfd023..02e92b4 100644
--- a/src/features/workspaces/ui/WorkspacesHome.svelte
+++ b/src/features/workspaces/ui/WorkspacesHome.svelte
@@ -1,92 +1,97 @@
<script lang="ts">
- import { onMount } from "svelte";
- import type { ComputerEntry } from "@dispatch/wire";
- import type { WorkspaceStore } from "../store.svelte";
- import { isValidSlug, workspacePath } from "../logic/route";
- import WorkspaceCard from "./WorkspaceCard.svelte";
+ import { onMount } from "svelte";
+ import type { ComputerEntry } from "@dispatch/wire";
+ import type { WorkspaceStore } from "../store.svelte";
+ import { isValidSlug, workspacePath } from "../logic/route";
+ import WorkspaceCard from "./WorkspaceCard.svelte";
- let {
- store,
- onNavigate,
- computers,
- }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[] } =
- $props();
+ let {
+ store,
+ onNavigate,
+ computers,
+ }: {
+ store: WorkspaceStore;
+ onNavigate: (path: string) => void;
+ computers: readonly ComputerEntry[];
+ } = $props();
- onMount(() => {
- void store.refresh();
- });
+ onMount(() => {
+ void store.refresh();
+ });
- let newSlug = $state("");
- let slugError = $state<string | null>(null);
+ let newSlug = $state("");
+ let slugError = $state<string | null>(null);
- const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
+ const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug));
- function createWorkspace(): void {
- const slug = newSlug.trim();
- if (!isValidSlug(slug)) {
- slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
- return;
- }
- slugError = null;
- newSlug = "";
- onNavigate(workspacePath(slug));
- }
+ function createWorkspace(): void {
+ const slug = newSlug.trim();
+ if (!isValidSlug(slug)) {
+ slugError = "Lowercase letters, digits, and hyphens (1–40 chars).";
+ return;
+ }
+ slugError = null;
+ newSlug = "";
+ onNavigate(workspacePath(slug));
+ }
</script>
<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6">
- <header class="flex items-center justify-between">
- <h1 class="text-2xl font-bold">Workspaces</h1>
- <a
- href="/default"
- class="btn btn-ghost btn-sm"
- onclick={(e) => {
- e.preventDefault();
- onNavigate("/default");
- }}
- >
- Default
- </a>
- </header>
+ <header class="flex items-center justify-between">
+ <h1 class="text-2xl font-bold">Workspaces</h1>
+ <a
+ href="/default"
+ class="btn btn-ghost btn-sm"
+ onclick={(e) => {
+ e.preventDefault();
+ onNavigate("/default");
+ }}
+ >
+ Default
+ </a>
+ </header>
- <form
- class="flex items-end gap-2"
- onsubmit={(e) => {
- e.preventDefault();
- createWorkspace();
- }}
- >
- <div class="flex-1">
- <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60">New workspace</label>
- <input
- id="new-ws"
- class="input input-bordered w-full"
- placeholder="my-workspace"
- bind:value={newSlug}
- autocomplete="off"
- spellcheck="false"
- />
- </div>
- <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
- </form>
- {#if slugError}
- <p class="text-xs text-error">{slugError}</p>
- {/if}
+ <form
+ class="flex items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ createWorkspace();
+ }}
+ >
+ <div class="flex-1">
+ <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60"
+ >New workspace</label
+ >
+ <input
+ id="new-ws"
+ class="input input-bordered w-full"
+ placeholder="my-workspace"
+ bind:value={newSlug}
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button>
+ </form>
+ {#if slugError}
+ <p class="text-xs text-error">{slugError}</p>
+ {/if}
- <div class="flex-1 overflow-y-auto">
- {#if store.loading && store.list.length === 0}
- <div class="flex h-32 items-center justify-center">
- <span class="loading loading-spinner loading-sm opacity-60"></span>
- </div>
- {:else if store.list.length === 0}
- <p class="py-8 text-center text-sm opacity-60">
- No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
- </p>
- {:else}
- <ul class="flex flex-col gap-2">
- {#each store.list as ws (ws.id)}
- <WorkspaceCard {ws} {store} {onNavigate} {computers} />
- {/each}
- </ul>
- {/if}
- </div>
+ <div class="flex-1 overflow-y-auto">
+ {#if store.loading && store.list.length === 0}
+ <div class="flex h-32 items-center justify-center">
+ <span class="loading loading-spinner loading-sm opacity-60"></span>
+ </div>
+ {:else if store.list.length === 0}
+ <p class="py-8 text-center text-sm opacity-60">
+ No workspaces yet. Create one above or visit <code>/your-name</code> in the URL.
+ </p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each store.list as ws (ws.id)}
+ <WorkspaceCard {ws} {store} {onNavigate} {computers} />
+ {/each}
+ </ul>
+ {/if}
+ </div>
</div>