summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/adapter/image-encode.test.ts20
-rw-r--r--src/features/chat/adapter/image-encode.ts67
-rw-r--r--src/features/chat/adapter/pending-inputs.ts49
-rw-r--r--src/features/chat/delivery.test.ts146
-rw-r--r--src/features/chat/index.ts32
-rw-r--r--src/features/chat/logic/composer.test.ts20
-rw-r--r--src/features/chat/logic/composer.ts11
-rw-r--r--src/features/chat/logic/image-compress.test.ts76
-rw-r--r--src/features/chat/logic/image-compress.ts77
-rw-r--r--src/features/chat/logic/tool-summary.test.ts456
-rw-r--r--src/features/chat/logic/tool-summary.ts460
-rw-r--r--src/features/chat/model-select.test.ts126
-rw-r--r--src/features/chat/model-select.ts147
-rw-r--r--src/features/chat/pending-inputs.ts12
-rw-r--r--src/features/chat/ports.ts11
-rw-r--r--src/features/chat/queued.test.ts207
-rw-r--r--src/features/chat/reasoning-effort.test.ts39
-rw-r--r--src/features/chat/reasoning-effort.ts98
-rw-r--r--src/features/chat/recovery.test.ts529
-rw-r--r--src/features/chat/store.svelte.ts577
-rw-r--r--src/features/chat/store.test.ts635
-rw-r--r--src/features/chat/test-helpers.ts39
-rw-r--r--src/features/chat/ui.test.ts818
-rw-r--r--src/features/chat/ui/CacheTimerBadge.svelte70
-rw-r--r--src/features/chat/ui/ChatView.svelte277
-rw-r--r--src/features/chat/ui/ChatView.test.ts558
-rw-r--r--src/features/chat/ui/Composer.svelte155
-rw-r--r--src/features/chat/ui/LazyCollapse.svelte46
-rw-r--r--src/features/chat/ui/ModelSelector.svelte214
-rw-r--r--src/features/chat/ui/PendingInputWarning.svelte47
-rw-r--r--src/features/chat/ui/PendingInputs.svelte16
-rw-r--r--src/features/chat/ui/PendingInputs.test.ts217
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte43
-rw-r--r--src/features/chat/ui/ToolCard.svelte274
-rw-r--r--src/features/chat/ui/tool-icons.ts52
35 files changed, 6296 insertions, 325 deletions
diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts
new file mode 100644
index 0000000..dbaa8ae
--- /dev/null
+++ b/src/features/chat/adapter/image-encode.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { convertToJpeg } from "./image-encode";
+
+describe("convertToJpeg (canvas edge — real environment, no mocks)", () => {
+ it("skips non-convertible formats without touching the canvas", async () => {
+ const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" });
+ await expect(convertToJpeg(jpeg)).resolves.toBe(null);
+ const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" });
+ await expect(convertToJpeg(gif)).resolves.toBe(null);
+ const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" });
+ await expect(convertToJpeg(unnamed)).resolves.toBe(null);
+ });
+
+ it("falls back to null on decode failure (no real canvas in this environment)", async () => {
+ const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" });
+ // jsdom has no `createImageBitmap` → the decode throws → the failure path
+ // must resolve to `null` (never reject), so sending is never blocked.
+ await expect(convertToJpeg(png)).resolves.toBe(null);
+ });
+});
diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts
new file mode 100644
index 0000000..4570a5a
--- /dev/null
+++ b/src/features/chat/adapter/image-encode.ts
@@ -0,0 +1,67 @@
+import {
+ computeResizedDimensions,
+ JPEG_QUALITY,
+ shouldConvertImage,
+ useConvertedImage,
+} from "../logic/image-compress";
+
+/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */
+export interface ConvertedJpeg {
+ readonly blob: Blob;
+ readonly mimeType: "image/jpeg";
+}
+
+/**
+ * The injected browser edge for attach-time compression: a `File` in, a
+ * (maybe) smaller JPEG out. Returns `null` when the source is not converted
+ * (non-convertible format) or the conversion is skipped/failed (decode or
+ * encode error, smaller-wins rule) — the caller then sends the original
+ * bytes. Compression must never block or break sending, so every failure is
+ * swallowed into `null`.
+ */
+export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>;
+
+/**
+ * Canvas-based JPEG re-encode: decode the source, rescale to cap the longest
+ * edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten
+ * any transparency onto a white background (JPEG has no alpha), and encode at
+ * `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result
+ * is used only under the smaller-wins rule.
+ *
+ * Thin by design — every decision comes from `../logic/image-compress`.
+ */
+export const convertToJpeg: ImageJpegEncoder = async (file: File) => {
+ try {
+ if (!shouldConvertImage(file.type)) return null;
+
+ const bitmap = await createImageBitmap(file);
+ const target = computeResizedDimensions(bitmap.width, bitmap.height);
+
+ const canvas = document.createElement("canvas");
+ canvas.width = target.width;
+ canvas.height = target.height;
+ const ctx = canvas.getContext("2d");
+ if (ctx === null) {
+ bitmap.close();
+ return null;
+ }
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+ // Flatten transparency: JPEG has no alpha channel.
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, target.width, target.height);
+ ctx.drawImage(bitmap, 0, 0, target.width, target.height);
+ bitmap.close();
+
+ const blob = await new Promise<Blob | null>((resolve) => {
+ canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY);
+ });
+ if (blob === null) return null;
+ // Smaller-wins: keep the original when the re-encode didn't actually help.
+ if (!useConvertedImage(file.size, blob.size)) return null;
+ return { blob, mimeType: "image/jpeg" };
+ } catch {
+ // Decode/encode failure → the original image is sent untouched.
+ return null;
+ }
+};
diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts
new file mode 100644
index 0000000..5798fdb
--- /dev/null
+++ b/src/features/chat/adapter/pending-inputs.ts
@@ -0,0 +1,49 @@
+import type { PendingInput, PendingInputStorage } from "../pending-inputs";
+
+export function createPendingInputStorage(
+ storage: Storage,
+ conversationId: string,
+): PendingInputStorage {
+ const prefix = `dispatch.pending-inputs:${conversationId}:`;
+ let known = new Map<string, string>();
+ return {
+ load() {
+ const inputs: PendingInput[] = [];
+ const loaded = new Map<string, string>();
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key === null || !key.startsWith(prefix)) continue;
+ const raw = storage.getItem(key);
+ if (raw === null) continue;
+ const input = JSON.parse(raw);
+ if (
+ (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") ||
+ input.request.conversationId !== conversationId ||
+ typeof input.request.requestId !== "string" ||
+ key !== prefix + input.request.requestId ||
+ (input.request.type === "chat.send"
+ ? typeof input.request.message !== "string"
+ : typeof input.request.text !== "string") ||
+ (input.rejection !== undefined && typeof input.rejection !== "string")
+ )
+ throw new Error("Invalid saved input");
+ inputs.push(input);
+ loaded.set(input.request.requestId, raw);
+ }
+ known = loaded;
+ return inputs;
+ },
+ save(inputs) {
+ // Per-request keys prevent another browser tab's send/receipt from overwriting ours.
+ // Quota/security failures reach the composer BEFORE it clears its staged input.
+ const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)]));
+ for (const [id, raw] of next) {
+ if (known.get(id) !== raw) storage.setItem(prefix + id, raw);
+ }
+ for (const id of known.keys()) {
+ if (!next.has(id)) storage.removeItem(prefix + id);
+ }
+ known = next;
+ },
+ };
+}
diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts
new file mode 100644
index 0000000..99ad2d4
--- /dev/null
+++ b/src/features/chat/delivery.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it, vi } from "vitest";
+import { createPendingInputStorage } from "./adapter/pending-inputs";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+function storage(): Storage {
+ const values = new Map<string, string>();
+ return {
+ get length() {
+ return values.size;
+ },
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => {
+ values.set(key, value);
+ },
+ removeItem: (key) => {
+ values.delete(key);
+ },
+ clear: () => values.clear(),
+ key: (index) => [...values.keys()][index] ?? null,
+ };
+}
+
+function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) {
+ const transport = createFakeTransport();
+ let id = 0;
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => `request-${++id}`,
+ pendingInputStorage: createPendingInputStorage(saved, "c"),
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync,
+ cache: createFakeCache().impl,
+ });
+ return { store, transport, saved };
+}
+
+const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }];
+
+describe("input delivery", () => {
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("saves %s input through refresh without automatically retransmitting", (method) => {
+ const f = fixture();
+ f.store[method]("keep me", images);
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.dispose();
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]);
+ expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me");
+ expect(reloaded.transport.sent).toEqual([]);
+ expect(reloaded.transport.sentQueue).toEqual([]);
+ reloaded.store.dispose();
+ });
+
+ it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" },
+ });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" });
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" });
+ expect(f.store.pendingInputs).toHaveLength(1);
+ f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" });
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("retains explicitly rejected input and images for recovery until discarded", () => {
+ const f = fixture();
+ f.store.send("mine", images);
+ f.store.handleDelta({
+ type: "chat.error",
+ conversationId: "c",
+ requestId: "request-1",
+ message: "disk full",
+ });
+ const reloaded = fixture(f.saved);
+ expect(reloaded.store.pendingInputs).toMatchObject([
+ { rejection: "disk full", request: { message: "mine", images } },
+ ]);
+ reloaded.store.discardPendingInput("request-1");
+ expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]);
+ f.store.dispose();
+ reloaded.store.dispose();
+ });
+
+ it.each([
+ "send",
+ "queueMessage",
+ ] as const)("does not clear or transmit %s input if saving fails", (method) => {
+ const saved = storage();
+ saved.setItem = () => {
+ throw new Error("quota exceeded");
+ };
+ const f = fixture(saved);
+ expect(() => f.store[method]("mine", images)).toThrow("quota exceeded");
+ expect(f.store.pendingInputs).toEqual([]);
+ expect(f.store.chunks).toEqual([]);
+ expect(f.transport.sent).toEqual([]);
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => {
+ let resolve!: (value: { turns: [] }) => void;
+ const first = {
+ promise: new Promise<{ turns: [] }>((done) => {
+ resolve = done;
+ }),
+ resolve: (value: { turns: [] }) => resolve(value),
+ };
+ const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] });
+ const f = fixture(storage(), metricsSync);
+ for (let i = 0; i < 100; i++) f.store.resync();
+ expect(metricsSync).toHaveBeenCalledTimes(1);
+ first.resolve({ turns: [] });
+ await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2));
+ f.store.dispose();
+ });
+});
+
+it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => {
+ const saved = storage();
+ const first = createPendingInputStorage(saved, "c");
+ const second = createPendingInputStorage(saved, "c");
+ first.load();
+ second.load();
+ const input = (requestId: string) => ({
+ request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId },
+ });
+ first.save([input("first")]);
+ second.save([input("second")]);
+ first.save([]);
+ expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]);
+});
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index cf57cea..41cb06f 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -2,23 +2,50 @@ export type {
ProviderRetryView,
RenderedChunk,
RenderGroup,
- ToolBatchEntry,
+ ToolEntry,
} from "../../core/chunks";
export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
-export { isVisionModel } from "./model-select";
+export { createPendingInputStorage } from "./adapter/pending-inputs";
+export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select";
+export {
+ CLAUDE_SUBSCRIPTION_KEY,
+ isClaudeSubscriptionModel,
+ isNamedAgentSelection,
+ isNumberedOpenCode,
+ isVisionModel,
+ migrateLegacyModelName,
+ modelKeyLabel,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ namedAgentsProviderLabel,
+ openCodeNumber,
+ providerKeysWithNamedAgents,
+} from "./model-select";
+export type { PendingInput, PendingInputStorage } from "./pending-inputs";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
EffortOption,
ReasoningEffortSaveResult,
SaveReasoningEffort,
+ SaveThinkingSelection,
+ SelectionOption,
+ SetThinkingRequest,
+ ThinkingResponse,
+ ThinkingSaveResult,
+ ThinkingSelection,
+ ThinkingSelectionSaveResult,
} from "./reasoning-effort";
export {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
@@ -28,6 +55,7 @@ export { default as CompactionView } from "./ui/CompactionView.svelte";
export type { ComposerStatus } from "./ui/Composer.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+export { default as PendingInputs } from "./ui/PendingInputs.svelte";
export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts
new file mode 100644
index 0000000..86e9dc4
--- /dev/null
+++ b/src/features/chat/logic/composer.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { stopButtonLabels } from "./composer";
+
+describe("stopButtonLabels", () => {
+ it.each([undefined, 0])("preserves plain Stop with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Stop",
+ ariaLabel: "Stop generation",
+ title: undefined,
+ });
+ });
+
+ it.each([1, 2])("labels the queued Stop action Flush with queued count %s", (count) => {
+ expect(stopButtonLabels(count)).toEqual({
+ text: "Flush",
+ ariaLabel: "Flush",
+ title: "Stop the current response and start the queued message now",
+ });
+ });
+});
diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts
new file mode 100644
index 0000000..e56a007
--- /dev/null
+++ b/src/features/chat/logic/composer.ts
@@ -0,0 +1,11 @@
+/** Stop still uses the same action; the backend carries queued input into a new turn. */
+export function stopButtonLabels(queuedMessageCount: number | undefined) {
+ if (queuedMessageCount !== undefined && queuedMessageCount >= 1) {
+ return {
+ text: "Flush",
+ ariaLabel: "Flush",
+ title: "Stop the current response and start the queued message now",
+ };
+ }
+ return { text: "Stop", ariaLabel: "Stop generation", title: undefined };
+}
diff --git a/src/features/chat/logic/image-compress.test.ts b/src/features/chat/logic/image-compress.test.ts
new file mode 100644
index 0000000..88726bf
--- /dev/null
+++ b/src/features/chat/logic/image-compress.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeResizedDimensions,
+ MAX_IMAGE_EDGE_PX,
+ shouldConvertImage,
+ useConvertedImage,
+} from "./image-compress";
+
+describe("shouldConvertImage", () => {
+ it("converts lossless sources (PNG, BMP)", () => {
+ expect(shouldConvertImage("image/png")).toBe(true);
+ expect(shouldConvertImage("image/bmp")).toBe(true);
+ });
+
+ it("skips already-lossy and animated formats", () => {
+ expect(shouldConvertImage("image/jpeg")).toBe(false);
+ expect(shouldConvertImage("image/webp")).toBe(false);
+ expect(shouldConvertImage("image/gif")).toBe(false);
+ });
+
+ it("leaves unknown or absent MIME types alone", () => {
+ expect(shouldConvertImage(undefined)).toBe(false);
+ expect(shouldConvertImage("")).toBe(false);
+ expect(shouldConvertImage("image/tiff")).toBe(false);
+ expect(shouldConvertImage("image/svg+xml")).toBe(false);
+ });
+});
+
+describe("computeResizedDimensions", () => {
+ it("never upscales a source already within the cap", () => {
+ expect(computeResizedDimensions(100, 50)).toEqual({ width: 100, height: 50 });
+ expect(computeResizedDimensions(2048, 1024)).toEqual({ width: 2048, height: 1024 });
+ expect(computeResizedDimensions(640, 640)).toEqual({ width: 640, height: 640 });
+ });
+
+ it("caps the longest edge at 2048", () => {
+ expect(computeResizedDimensions(4096, 2048)).toEqual({ width: 2048, height: 1024 });
+ expect(computeResizedDimensions(8192, 4096)).toEqual({ width: 2048, height: 1024 });
+ });
+
+ it("caps the longest edge regardless of orientation (portrait)", () => {
+ expect(computeResizedDimensions(2000, 3000)).toEqual({ width: 1365, height: 2048 });
+ });
+
+ it("preserves aspect ratio on the rescale", () => {
+ const { width, height } = computeResizedDimensions(3000, 2000);
+ expect(width).toBe(2048);
+ // 2000 * (2048/3000) = 1365.33… → 1365
+ expect(height).toBe(1365);
+ expect(width / height).toBeCloseTo(3000 / 2000, 2);
+ });
+
+ it("handles extreme aspect ratios without exceeding the cap", () => {
+ expect(computeResizedDimensions(5000, 10)).toEqual({ width: 2048, height: 4 });
+ expect(computeResizedDimensions(10, 5000)).toEqual({ width: 4, height: 2048 });
+ });
+
+ it("honors a custom cap", () => {
+ expect(computeResizedDimensions(1000, 500, 250)).toEqual({ width: 250, height: 125 });
+ });
+
+ it("uses the default 2048px cap constant", () => {
+ expect(MAX_IMAGE_EDGE_PX).toBe(2048);
+ });
+});
+
+describe("useConvertedImage", () => {
+ it("uses the conversion only when it is strictly fewer bytes", () => {
+ expect(useConvertedImage(3_000_000, 300_000)).toBe(true);
+ });
+
+ it("keeps the original when equal or larger", () => {
+ expect(useConvertedImage(300_000, 300_000)).toBe(false);
+ expect(useConvertedImage(100_000, 300_000)).toBe(false);
+ });
+});
diff --git a/src/features/chat/logic/image-compress.ts b/src/features/chat/logic/image-compress.ts
new file mode 100644
index 0000000..a9037b8
--- /dev/null
+++ b/src/features/chat/logic/image-compress.ts
@@ -0,0 +1,77 @@
+/**
+ * Pure decision logic for attach-time image compression (the `Compress`
+ * toggle on the composer's staged-image thumbnails).
+ *
+ * The pipeline (re-encode to JPEG + rescale) runs at the browser edge
+ * (`../adapter/image-encode`); this module owns every DECISION so it stays
+ * pure `input → output` and unit-testable without a DOM:
+ *
+ * - `shouldConvertImage` — which source formats get converted.
+ * - `computeResizedDimensions` — the rescale math (cap the longest edge,
+ * never upscale, preserve aspect).
+ * - `useConvertedImage` — the smaller-wins rule.
+ */
+
+/** JPEG encode quality for the canvas re-encode. */
+export const JPEG_QUALITY = 0.85;
+
+/** Longest-edge cap (px) for the rescale. Never upscale past the source. */
+export const MAX_IMAGE_EDGE_PX = 2048;
+
+/**
+ * Source formats that get converted. Only LOSSLESS sources are re-encoded:
+ * PNG (the screenshot case — multi-MB PNGs are the reason this exists) plus
+ * BMP (trivially canvas-decodable in every browser). Already-lossy formats
+ * (JPEG/WebP) and animated ones (GIF) are sent verbatim — recompressing them
+ * degrades quality and saves nothing, and re-encoding a GIF destroys its
+ * animation. TIFF is NOT listed: browsers can't natively decode it, so the
+ * canvas path would always fall back to the original anyway. An oversized
+ * JPEG stays untouched (the backend's body-budget safety net handles the
+ * extreme case).
+ */
+const CONVERTIBLE_MIME_TYPES = new Set(["image/png", "image/bmp"]);
+
+/**
+ * Whether a source MIME type is converted to JPEG at staging time.
+ * Unknown/absent MIME types are left alone (conservative — never gamble on
+ * bytes we can't classify).
+ */
+export function shouldConvertImage(mimeType: string | undefined): boolean {
+ return mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType);
+}
+
+/** Pixel dimensions of a raster image. */
+export interface Dimensions {
+ readonly width: number;
+ readonly height: number;
+}
+
+/**
+ * Target dimensions after the rescale: cap the LONGEST edge at `maxEdge`
+ * (default `MAX_IMAGE_EDGE_PX`), never upscale (a smaller source passes
+ * through unchanged), and preserve aspect ratio.
+ *
+ * Pure math — the canvas draw itself lives at the browser edge.
+ */
+export function computeResizedDimensions(
+ width: number,
+ height: number,
+ maxEdge: number = MAX_IMAGE_EDGE_PX,
+): Dimensions {
+ const longest = Math.max(width, height);
+ if (longest <= maxEdge) return { width, height };
+ const scale = maxEdge / longest;
+ // Rounding can never exceed the cap: each scaled edge is ≤ maxEdge before
+ // rounding, and the only values that round up land exactly ON the cap.
+ return { width: Math.round(width * scale), height: Math.round(height * scale) };
+}
+
+/**
+ * The smaller-wins rule: the converted JPEG is used only when it is actually
+ * FEWER bytes than the original (equal or larger → keep the original).
+ * Compared on raw byte counts (File/Blob `size`); base64 inflation affects
+ * both sides equally so the ratio is preserved.
+ */
+export function useConvertedImage(originalBytes: number, convertedBytes: number): boolean {
+ return convertedBytes < originalBytes;
+}
diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts
new file mode 100644
index 0000000..c0caa0d
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.test.ts
@@ -0,0 +1,456 @@
+import { describe, expect, it } from "vitest";
+import type { ToolEntry } from "../../../core/chunks";
+import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary";
+
+function entry(
+ name: string,
+ input: unknown,
+ content: string | null = "ok",
+ isError = false,
+): ToolEntry {
+ return {
+ toolCallId: "a",
+ toolName: name,
+ call: { type: "tool-call", toolCallId: "a", toolName: name, input },
+ result:
+ content === null
+ ? null
+ : { type: "tool-result", toolCallId: "a", toolName: name, content, isError },
+ };
+}
+
+const view = (name: string, input: unknown, content: string | null = "ok", isError = false) =>
+ viewToolEntry(entry(name, input, content, isError));
+const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`);
+
+describe("tool labels", () => {
+ it.each([
+ ["run_shell", "Run command"],
+ ["read_file", "Read file"],
+ ["edit_file", "Edit file"],
+ ["write_file", "Write file"],
+ ["web_search", "Web search"],
+ ["lsp", "Code intelligence"],
+ ["todo_write", "Update to-dos"],
+ ["load_skill", "Load skill"],
+ ["consult_vision", "Ask vision model"],
+ ["read_image", "Read image"],
+ ["youtube_transcript", "YouTube transcript"],
+ ["summon_agent", "Summon agent"],
+ ["send_agent_message", "Message agent"],
+ ["read_agent", "Read agent reply"],
+ ["open_agent", "Open agent"],
+ ["cancel_agent_message", "Cancel agent message"],
+ ["list_agents", "List agents"],
+ ["list_agent_models", "List agent models"],
+ ])("built-in %s → %s", (name, label) => {
+ expect(toolLabel(name)).toBe(label);
+ expect(view(name, {}).label).toBe(label);
+ expect(view(name, {}).name).toBe(name);
+ });
+ it.each([
+ ["github__create_issue", "Github: Create issue"],
+ ["mcp__github__create_issue", "Github: Create issue"],
+ ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"],
+ ["scaffold_echo", "Scaffold echo"],
+ ["fetchUrl", "Fetch url"],
+ ["some-kebab-tool", "Some kebab tool"],
+ ["toString", "To string"],
+ ])("unknown %s is humanized → %s", (name, label) => {
+ expect(toolLabel(name)).toBe(label);
+ });
+});
+
+describe("tool icons", () => {
+ const builtIns = [
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "read_image",
+ "youtube_transcript",
+ "summon_agent",
+ "send_agent_message",
+ "read_agent",
+ "open_agent",
+ "cancel_agent_message",
+ "list_agents",
+ "list_agent_models",
+ ];
+ it("gives every built-in tool its own distinct icon", () => {
+ const icons = builtIns.map(toolIcon);
+ expect(icons).not.toContain("tool");
+ expect(icons).not.toContain("mcp");
+ expect(new Set(icons).size).toBe(builtIns.length);
+ expect(view("run_shell", {}).icon).toBe("terminal");
+ });
+ it.each([
+ ["github__create_issue", "mcp"],
+ ["mcp__github__create_issue", "mcp"],
+ ["scaffold_echo", "tool"],
+ ["__proto__", "tool"],
+ ["constructor", "tool"],
+ ])("%s → %s", (name, icon) => {
+ expect(toolIcon(name)).toBe(icon);
+ });
+});
+
+describe("tool summaries", () => {
+ it.each([
+ ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"],
+ ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"],
+ ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"],
+ ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"],
+ ["read_file", { path: "src/a.ts" }, "src/a.ts"],
+ ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"],
+ ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"],
+ ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"],
+ ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"],
+ [
+ "lsp",
+ { operation: "references", path: "src/a.ts", line: 12, character: 3 },
+ "references src/a.ts:12:3",
+ ],
+ ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"],
+ [
+ "todo_write",
+ { todos: [{ status: "pending" }, { status: "completed" }, null] },
+ "3 tasks · 1 completed",
+ ],
+ ["load_skill", { name: "research_methodology" }, "research_methodology"],
+ [
+ "consult_vision",
+ { question: "What is in this picture?", imageIds: [1] },
+ "What is in this picture?",
+ ],
+ ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"],
+ ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"],
+ ["summon_agent", { task: "Review the patch" }, "Review the patch"],
+ ["summon_agent", { agent: "Opus" }, "Opus"],
+ ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"],
+ ["read_agent", { conversationId: "abcd" }, "abcd"],
+ ["open_agent", { conversationId: "abcd" }, "abcd"],
+ ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"],
+ ["list_agents", { relationship: "descendants" }, "descendants"],
+ ["list_agents", { query: "review" }, "review"],
+ ["list_agent_models", { query: "Opus" }, "Opus"],
+ ])("%s derives a meaningful call summary from %j", (name, input, expected) => {
+ expect(view(name, input).callSummary).toBe(expected);
+ });
+
+ it("formats edit call badges and old/new details without hiding multiline text", () => {
+ const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" };
+ const card = view("edit_file", input);
+ expect(card.callBadges).toEqual([
+ { label: "+3", tone: "success" },
+ { label: "−2", tone: "error" },
+ ]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: "a.ts",
+ oldText: input.oldString,
+ newText: input.newString,
+ });
+ expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([
+ { label: "+1", tone: "success" },
+ { label: "−0", tone: "error" },
+ ]);
+ expect(view("edit_file", { path: "a" }).callBadges).toEqual([]);
+ });
+ it("omits edit diffstats after failure while retaining attempted edits in call details", () => {
+ const input = { path: "src/missing.ts", oldString: "foo", newString: "bar" };
+ const card = view("edit_file", input, "Error: oldString not found", true);
+ expect(card.callBadges).toEqual([]);
+ expect(card.callDetail).toEqual({
+ kind: "edit",
+ path: input.path,
+ oldText: "foo",
+ newText: "bar",
+ });
+ expect(card.resultSummary).toBe("failed");
+ });
+ it("preserves exact shell commands in the expanded detail", () => {
+ expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({
+ kind: "command",
+ text: "ls\n pwd",
+ });
+ });
+ it("shows scalar fields and pretty nested JSON", () => {
+ expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "path", value: "a.ts", multiline: false },
+ { key: "count", value: "2", multiline: false },
+ { key: "nested", value: '{\n "a": true\n}', multiline: true },
+ ],
+ });
+ });
+ it("formats short scalar arrays inline, retaining quotes, escapes, nulls and empty arrays", () => {
+ expect(
+ view("mcp", {
+ labels: ["ui", "chat"],
+ scalars: [1, true, null],
+ empty: [],
+ escaped: ["a\nb"],
+ }).callDetail,
+ ).toEqual({
+ kind: "fields",
+ fields: [
+ { key: "labels", value: '["ui", "chat"]', multiline: false },
+ { key: "scalars", value: "[1, true, null]", multiline: false },
+ { key: "empty", value: "[]", multiline: false },
+ { key: "escaped", value: '["a\\nb"]', multiline: false },
+ ],
+ });
+ });
+ it("keeps long arrays and nested structures pretty-printed and complete", () => {
+ const input = { long: ["x".repeat(80)], nested: [{ a: 1 }], mixed: [1, { a: 1 }] };
+ const card = view("mcp", input);
+ if (card.callDetail.kind !== "fields") throw new Error("expected fields");
+ expect(card.callDetail.fields.map((field) => field.multiline)).toEqual([true, true, true]);
+ expect(card.callDetail.fields.map((field) => JSON.parse(field.value))).toEqual(
+ Object.values(input),
+ );
+ });
+ it.each([
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ ])("generic fallback recognizes %s", (key) => {
+ expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target");
+ });
+ it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => {
+ expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a");
+ expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x");
+ // Only nested values → no summary rather than a JSON dump.
+ expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe("");
+ });
+ it.each([
+ "not JSON",
+ "{bad",
+ 42,
+ true,
+ [1, "a"],
+ '"hi"',
+ ])("tolerates malformed/non-object input %j", (input) => {
+ expect(() => view("read_file", input)).not.toThrow();
+ expect(view("read_file", input).callSummary).not.toBe("");
+ expect(view("read_file", input).callDetail.kind).toBe("fields");
+ });
+ it.each([
+ null,
+ undefined,
+ {},
+ [],
+ "",
+ " ",
+ "null",
+ "{}",
+ ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => {
+ const card = view("list_agent_models", input);
+ expect(card.callSummary).toBe("");
+ expect(card.callDetail).toEqual({ kind: "none" });
+ });
+ it.each([
+ "run_shell",
+ "read_file",
+ "edit_file",
+ "write_file",
+ "web_search",
+ "lsp",
+ "todo_write",
+ "load_skill",
+ "consult_vision",
+ "youtube_transcript",
+ "summon_agent",
+ "read_agent",
+ ])("%s falls back safely for missing arguments", (name) => {
+ for (const input of [null, undefined, {}]) {
+ expect(() => view(name, input)).not.toThrow();
+ expect(view(name, input).callSummary).toBe("");
+ expect(view(name, input).callDetail).toEqual({ kind: "none" });
+ }
+ expect(view(name, "{bad").callSummary).toBe("{bad");
+ });
+ it("parses JSON-string inputs", () => {
+ const card = view("run_shell", '{"command":"bun run test"}');
+ expect(card.callSummary).toBe("bun run test");
+ expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" });
+ });
+ it("handles unexpected built-in argument types and prototype-looking tool names", () => {
+ expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong");
+ expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields");
+ expect(view("constructor", { path: "a" }).callSummary).toBe("a");
+ expect(view("__proto__", {}).callSummary).toBe("");
+ expect(view("__proto__", {}).label).toBe("Proto");
+ expect(
+ view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary,
+ ).toBe("good");
+ });
+ it("does not throw on unserializable input", () => {
+ const cyclic: Record<string, unknown> = {};
+ cyclic.self = cyclic;
+ expect(view("mcp", cyclic).callSummary).toBe("");
+ expect(view("mcp", cyclic).callDetail).toEqual({
+ kind: "fields",
+ fields: [{ key: "self", value: "[unserializable input]", multiline: false }],
+ });
+ expect(() => view("mcp", { integer: 1n })).not.toThrow();
+ });
+ it("rejects ancestor cycles but preserves shared non-cyclic tool values", () => {
+ const nested: Record<string, unknown> = { name: "nested" };
+ const root = { nested };
+ nested.parent = root;
+ expect(view("mcp", { payload: root }).callDetail).toEqual({
+ kind: "fields",
+ fields: [{ key: "payload", value: "[unserializable input]", multiline: false }],
+ });
+ const shared = { name: "shared" };
+ const payload = { first: shared, second: shared, values: [shared, shared] };
+ const detail = view("mcp", { payload }).callDetail;
+ expect(detail.kind).toBe("fields");
+ if (detail.kind !== "fields") throw new Error("Expected fields");
+ expect(detail.fields[0]?.value).toBe(JSON.stringify(payload, null, 2));
+ });
+ it("caps and whitespace-collapses header strings but retains full detail", () => {
+ const command = ` echo\n${"x".repeat(300)}`;
+ const card = view("run_shell", { command });
+ expect(card.callSummary.length).toBe(120);
+ expect(card.callSummary.endsWith("…")).toBe(true);
+ expect(card.callSummary).not.toMatch(/\n/);
+ expect(card.callDetail).toEqual({ kind: "command", text: command });
+ });
+ it("represents pending entries with no response summary/output", () => {
+ expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({
+ status: "pending",
+ resultSummary: null,
+ output: null,
+ });
+ });
+ it("represents orphan results with unknown call details, not pending", () => {
+ const e = { ...entry("read_file", {}, "data"), call: null };
+ expect(viewToolEntry(e)).toMatchObject({
+ name: "read_file",
+ status: "ok",
+ callSummary: "Call in earlier history",
+ resultSummary: "1 line",
+ });
+ expect(viewToolEntry(e).callDetail).toEqual({
+ kind: "unavailable",
+ text: "This call is earlier in the conversation. Show earlier messages to see it.",
+ });
+ });
+});
+
+describe("tool response facts", () => {
+ it.each([
+ ["run_shell", "one\ntwo\n", false, "2 lines"],
+ ["run_shell", "done", false, "1 line"],
+ ["read_file", "1\tfile text\n", false, "1 line"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ["mcp", "", false, "no output"],
+ ["mcp", " \n\t", false, "no output"],
+ ["mcp", "a short response", false, "a short response"],
+ ["mcp", "A successful response with more than forty characters of text", false, "1 line"],
+ ["mcp", "x".repeat(200), false, "1 line"],
+ ["web_search", "a\nb\nc", false, "3 lines"],
+ ["run_shell", "", true, "failed"],
+ ["mcp", "Permission denied", true, "failed"],
+ ["run_shell", "error\ndetail", true, "failed"],
+ ["run_shell", "partial\n\n[Command timed out]", true, "timed out"],
+ ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"],
+ ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · truncated"],
+ ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"],
+ [
+ "edit_file",
+ 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2',
+ false,
+ "3 replacements · diagnostics",
+ ],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"],
+ ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"],
+ ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => {
+ const card = view(name, {}, content, isError);
+ expect(card.resultSummary).toBe(expected);
+ expect(card.status).toBe(isError ? "error" : "ok");
+ expect(card.output?.isError).toBe(isError);
+ });
+ it.each([
+ ["run_shell", "oops\ndetails", true, "failed"],
+ ["run_shell", "partial\n[Command timed out]", true, "timed out"],
+ ["write_file", 'Created "a.ts" (4200 bytes).', false, "created"],
+ ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote"],
+ ["edit_file", 'Replaced 3 occurrences in "a.ts".\nDiagnostics: warning', false, "replaced"],
+ ["run_shell", "a\nb\nc\nd", false, "4 lines"],
+ ["mcp", "Created issue #42", false, "1 line"],
+ ["mcp", "", false, "no output"],
+ ["read_file", "(empty file: a.ts)", false, "empty file"],
+ ])("%s supplies a complete compact response fact", (name, content, isError, expected) => {
+ expect(view(name, {}, content, isError).compactResultSummary).toBe(expected);
+ });
+ it("never puts error content in header summaries, but retains it verbatim in output", () => {
+ const content = 'Error: oldString not found in content of "src/missing.ts".';
+ const card = view("edit_file", { path: "src/missing.ts" }, content, true);
+ expect(card.resultSummary).toBe("failed");
+ expect(card.compactResultSummary).toBe("failed");
+ expect(card.output).toEqual({ kind: "full", lines: [content], isError: true });
+ expect(card.callSummary).toBe("src/missing.ts");
+ expect(view("mcp", {}, null).compactResultSummary).toBeNull();
+ });
+ it("does not infer success on an error that resembles a write result", () => {
+ expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch(
+ /^failed/,
+ );
+ });
+});
+
+describe("tool output elision", () => {
+ it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({
+ kind: "full",
+ lines: lines(n),
+ isError: false,
+ });
+ });
+ it.each([
+ 13, 14, 100,
+ ])("shows first/last six of %i lines, retaining everything for expansion", (n) => {
+ expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({
+ kind: "elided",
+ head: lines(n).slice(0, 6),
+ hiddenCount: n - 12,
+ tail: lines(n).slice(-6),
+ all: lines(n),
+ isError: true,
+ });
+ });
+ it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => {
+ expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full");
+ expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({
+ kind: "elided",
+ hiddenCount: 1,
+ });
+ });
+ it("preserves meaningful blank lines including an extra trailing blank line", () => {
+ expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({
+ kind: "full",
+ lines: ["a", "", "b", ""],
+ isError: false,
+ });
+ });
+});
diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts
new file mode 100644
index 0000000..2380adc
--- /dev/null
+++ b/src/features/chat/logic/tool-summary.ts
@@ -0,0 +1,460 @@
+import type { ToolEntry } from "../../../core/chunks";
+
+export interface ToolCardView {
+ /** The raw wire tool name (tooltip / debugging). */
+ readonly name: string;
+ /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */
+ readonly label: string;
+ /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */
+ readonly icon: ToolIconKey;
+ readonly status: "pending" | "ok" | "error";
+ readonly callSummary: string;
+ readonly callBadges: readonly {
+ readonly label: string;
+ readonly tone: "success" | "error";
+ }[];
+ readonly resultSummary: string | null;
+ readonly compactResultSummary: string | null;
+ readonly callDetail:
+ | { readonly kind: "unavailable"; readonly text: string }
+ /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */
+ | { readonly kind: "none" }
+ | { readonly kind: "command"; readonly text: string }
+ | {
+ readonly kind: "edit";
+ readonly path: string;
+ readonly oldText: string;
+ readonly newText: string;
+ }
+ | {
+ readonly kind: "fields";
+ readonly fields: readonly {
+ readonly key: string;
+ readonly value: string;
+ readonly multiline: boolean;
+ }[];
+ };
+ readonly output: ToolOutputView | null;
+}
+
+export type ToolOutputView =
+ | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean }
+ | {
+ readonly kind: "elided";
+ readonly head: readonly string[];
+ readonly hiddenCount: number;
+ readonly tail: readonly string[];
+ readonly all: readonly string[];
+ readonly isError: boolean;
+ };
+
+type Args = Readonly<Record<string, unknown>>;
+type CallSummary = (args: Args) => string;
+
+function oneLine(text: string, cap = 120): string {
+ const compact = text.replace(/\s+/g, " ").trim();
+ return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact;
+}
+
+function normalize(input: unknown): unknown {
+ if (typeof input !== "string") return input;
+ try {
+ return JSON.parse(input);
+ } catch {
+ return input;
+ }
+}
+
+function asArgs(input: unknown): Args {
+ return typeof input === "object" && input !== null && !Array.isArray(input)
+ ? (input as Args)
+ : {};
+}
+
+function serialize(input: unknown, pretty = false): string {
+ if (typeof input === "string") return input;
+ try {
+ // Detect ancestor cycles before the native serializer recurses. Bun's
+ // pretty JSON cycle detection can otherwise consume seconds of UI time.
+ // Track ancestors, not every visited object: shared non-cyclic values are valid.
+ const ancestors: object[] = [];
+ return (
+ JSON.stringify(
+ input,
+ function (this: unknown, _key, value: unknown) {
+ if (value !== null && typeof value === "object") {
+ while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop();
+ if (ancestors.includes(value)) throw new TypeError("Circular tool input");
+ ancestors.push(value);
+ }
+ return value;
+ },
+ pretty ? 2 : undefined,
+ ) ?? String(input)
+ );
+ } catch {
+ return "[unserializable input]";
+ }
+}
+
+/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */
+function fieldValue(value: unknown): string {
+ if (
+ Array.isArray(value) &&
+ value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item))
+ ) {
+ try {
+ const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`;
+ if (inline.length <= 80) return inline;
+ } catch {
+ // Non-wire values still use the safe serializer below.
+ }
+ }
+ return serialize(value, true);
+}
+
+function arg(args: Args, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = Object.hasOwn(args, key) ? args[key] : undefined;
+ if (typeof value === "string" && value.trim()) return value;
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
+ }
+ return "";
+}
+
+function lineCount(text: string): number {
+ return text === "" ? 0 : outputLines(text).length;
+}
+
+/** Drop one final newline terminator, not meaningful blank lines within the output. */
+function outputLines(content: string): string[] {
+ const normalized = content.replace(/\r\n/g, "\n");
+ return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n");
+}
+
+function readSummary(args: Args): string {
+ const path = arg(args, "path");
+ const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1;
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null;
+ return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`;
+}
+
+function lspSummary(args: Args): string {
+ const line = arg(args, "line");
+ const character = arg(args, "character");
+ return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`;
+}
+
+function todoSummary(args: Args): string {
+ if (!Array.isArray(args.todos)) return "";
+ const done = args.todos.filter((t) => asArgs(t).status === "completed").length;
+ return `${args.todos.length} tasks · ${done} completed`;
+}
+
+const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model");
+const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message");
+
+/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */
+const TOOL_LABELS: Readonly<Record<string, string>> = {
+ run_shell: "Run command",
+ read_file: "Read file",
+ edit_file: "Edit file",
+ write_file: "Write file",
+ web_search: "Web search",
+ lsp: "Code intelligence",
+ todo_write: "Update to-dos",
+ load_skill: "Load skill",
+ consult_vision: "Ask vision model",
+ read_image: "Read image",
+ youtube_transcript: "YouTube transcript",
+ summon_agent: "Summon agent",
+ send_agent_message: "Message agent",
+ read_agent: "Read agent reply",
+ open_agent: "Open agent",
+ cancel_agent_message: "Cancel agent message",
+ list_agents: "List agents",
+ list_agent_models: "List agent models",
+};
+
+/**
+ * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor
+ * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`,
+ * else the generic `tool` wrench.
+ */
+export type ToolIconKey =
+ | "terminal"
+ | "file-read"
+ | "file-edit"
+ | "file-write"
+ | "search"
+ | "code"
+ | "todos"
+ | "skill"
+ | "vision"
+ | "image"
+ | "youtube"
+ | "agent-summon"
+ | "agent-send"
+ | "agent-read"
+ | "agent-open"
+ | "agent-cancel"
+ | "agents"
+ | "models"
+ | "mcp"
+ | "tool";
+
+const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = {
+ run_shell: "terminal",
+ read_file: "file-read",
+ edit_file: "file-edit",
+ write_file: "file-write",
+ web_search: "search",
+ lsp: "code",
+ todo_write: "todos",
+ load_skill: "skill",
+ consult_vision: "vision",
+ read_image: "image",
+ youtube_transcript: "youtube",
+ summon_agent: "agent-summon",
+ send_agent_message: "agent-send",
+ read_agent: "agent-read",
+ open_agent: "agent-open",
+ cancel_agent_message: "agent-cancel",
+ list_agents: "agents",
+ list_agent_models: "models",
+};
+
+/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */
+export function toolIcon(name: string): ToolIconKey {
+ if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool";
+ return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool";
+}
+
+/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */
+function sentenceCase(raw: string): string {
+ const words = raw
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .split(/[\s_\-.]+/)
+ .filter((w) => w !== "")
+ .map((w) => w.toLowerCase());
+ if (words.length === 0) return raw;
+ const text = words.join(" ");
+ return text.charAt(0).toUpperCase() + text.slice(1);
+}
+
+/**
+ * Generic English name for an unknown tool. MCP tools arrive namespaced as
+ * `<serverId>__<toolName>` (backend `mcp/registry.ts`), shown as "Server: Tool name".
+ */
+function humanize(name: string): string {
+ const parts = name.split("__").filter((p) => p !== "");
+ if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift();
+ if (parts.length >= 2) {
+ const server = sentenceCase(parts[0] ?? "");
+ return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`;
+ }
+ return sentenceCase(name);
+}
+
+/** The English display name for a tool (built-in table, else humanized). */
+export function toolLabel(name: string): string {
+ return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name);
+}
+
+/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */
+function isEmptyInput(input: unknown): boolean {
+ if (input === undefined || input === null) return true;
+ if (typeof input === "string") return input.trim() === "";
+ if (Array.isArray(input)) return input.length === 0;
+ return typeof input === "object" && Object.keys(input).length === 0;
+}
+
+/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */
+function scalarPairs(args: Args): string {
+ return Object.entries(args)
+ .filter(
+ ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "",
+ )
+ .map(([k, v]) => `${k}: ${String(v)}`)
+ .join(" · ");
+}
+
+/** Optional built-in polish; all unknown/MCP names use the same generic path. */
+const summaries: Readonly<Record<string, CallSummary>> = {
+ run_shell: (args) => arg(args, "command"),
+ read_file: readSummary,
+ edit_file: (args) => arg(args, "path"),
+ write_file: (args) => arg(args, "path"),
+ web_search: (args) => arg(args, "query", "url"),
+ lsp: lspSummary,
+ todo_write: todoSummary,
+ load_skill: (args) => arg(args, "name"),
+ consult_vision: (args) => arg(args, "question", "path"),
+ youtube_transcript: (args) => arg(args, "url"),
+ summon_agent: summarizeAgent,
+ send_agent_message: summarizeConversation,
+ read_agent: summarizeConversation,
+ open_agent: summarizeConversation,
+ cancel_agent_message: summarizeConversation,
+ list_agents: (args) => arg(args, "query", "relationship", "workspaceId"),
+ list_agent_models: (args) => arg(args, "query"),
+};
+
+function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] {
+ if (name === "run_shell" && typeof args.command === "string") {
+ return { kind: "command", text: args.command };
+ }
+ if (name === "edit_file" && typeof args.path === "string") {
+ return {
+ kind: "edit",
+ path: args.path,
+ oldText: typeof args.oldString === "string" ? args.oldString : "",
+ newText: typeof args.newString === "string" ? args.newString : "",
+ };
+ }
+ if (isEmptyInput(input)) return { kind: "none" };
+ const entries = Object.entries(args);
+ return {
+ kind: "fields",
+ fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => {
+ const text = fieldValue(value);
+ return { key: String(key), value: text, multiline: text.includes("\n") };
+ }),
+ };
+}
+
+function bytesLabel(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function responseSummary(name: string, content: string, isError: boolean): string {
+ const lines = outputLines(content);
+ const first = oneLine(lines.find((line) => line.trim()) ?? "", 72);
+ const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`;
+ const timeout = /\[Command timed out\]/i.test(content);
+ const truncated = /\[Output truncated[^\]]*\]/i.test(content);
+ let summary: string;
+ if (timeout) summary = "timed out";
+ else if (isError) summary = "failed";
+ else if (!content.trim()) summary = "no output";
+ else if (/^\(empty file\b/i.test(content)) summary = "empty file";
+ else {
+ const replaced =
+ name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null;
+ const written =
+ name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null;
+ if (replaced) {
+ summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`;
+ if (content.trim().includes("\n")) summary += " · diagnostics";
+ } else if (written) {
+ summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`;
+ } else if (
+ name === "run_shell" ||
+ name === "read_file" ||
+ lines.length > 1 ||
+ first.length > 60
+ ) {
+ summary = count;
+ } else summary = first;
+ }
+ const label = `${summary}${truncated ? " · truncated" : ""}`;
+ // Even the wide header must leave room for the call; prefer a whole short
+ // fact over chopping an arbitrary one-line response halfway through a word.
+ return label.length <= 40 ? label : compactResponseSummary(label, lines.length);
+}
+
+/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */
+function compactResponseSummary(summary: string, count: number): string {
+ const fact = summary.split(" · ")[0] ?? summary;
+ if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact))
+ return fact;
+ if (/^\d+ replacements?$/.test(fact)) return "replaced";
+ return `${count} ${count === 1 ? "line" : "lines"}`;
+}
+
+export function viewToolEntry(entry: ToolEntry): ToolCardView {
+ const input = normalize(entry.call?.input);
+ const args = asArgs(input);
+ const summarize = Object.hasOwn(summaries, entry.toolName)
+ ? summaries[entry.toolName]
+ : undefined;
+ const generic = arg(
+ args,
+ "command",
+ "path",
+ "file_path",
+ "query",
+ "url",
+ "pattern",
+ "name",
+ "question",
+ "title",
+ "task",
+ "message",
+ "conversationId",
+ );
+ const builtInSummary = summarize?.(args).trim();
+ const callSummary =
+ entry.call === null
+ ? entry.recovering
+ ? "Recovering tool input…"
+ : "Call in earlier history"
+ : isEmptyInput(input)
+ ? ""
+ : oneLine(
+ builtInSummary ||
+ generic ||
+ scalarPairs(args) ||
+ // Non-object input (e.g. a bare string); objects with only nested
+ // values stay unsummarized rather than dumping JSON braces.
+ (Object.keys(args).length === 0 ? serialize(input) : ""),
+ );
+ const callBadges: ToolCardView["callBadges"][number][] = [];
+ if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) {
+ if (typeof args.oldString === "string" && typeof args.newString === "string") {
+ callBadges.push(
+ { label: `+${lineCount(args.newString)}`, tone: "success" },
+ { label: `−${lineCount(args.oldString)}`, tone: "error" },
+ );
+ }
+ }
+ const result = entry.result;
+ const lines = result === null ? [] : outputLines(result.content);
+ const resultSummary =
+ result === null ? null : responseSummary(entry.toolName, result.content, result.isError);
+ return {
+ name: entry.toolName,
+ label: toolLabel(entry.toolName),
+ icon: toolIcon(entry.toolName),
+ status: result === null ? "pending" : result.isError ? "error" : "ok",
+ callSummary,
+ callBadges,
+ callDetail:
+ entry.call === null
+ ? {
+ kind: "unavailable",
+ text: entry.recovering
+ ? "Tool input is unavailable until it is persisted. Recovering from conversation history…"
+ : "This call is earlier in the conversation. Show earlier messages to see it.",
+ }
+ : detail(entry.toolName, input, args),
+ resultSummary,
+ compactResultSummary:
+ resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length),
+ output:
+ result === null
+ ? null
+ : lines.length <= 12
+ ? { kind: "full", lines, isError: result.isError }
+ : {
+ kind: "elided",
+ head: lines.slice(0, 6),
+ hiddenCount: lines.length - 12,
+ tail: lines.slice(-6),
+ all: lines,
+ isError: result.isError,
+ },
+ };
+}
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
index 6d3081d..523f1d8 100644
--- a/src/features/chat/model-select.test.ts
+++ b/src/features/chat/model-select.test.ts
@@ -1,9 +1,19 @@
import { describe, expect, it } from "vitest";
import {
+ isClaudeSubscriptionModel,
+ isNamedAgentSelection,
isVisionModel,
joinModelName,
+ migrateLegacyModelName,
+ modelKeyLabel,
modelKeys,
modelsForKey,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ namedAgentsProviderLabel,
+ openCodeNumber,
+ providerKeysWithNamedAgents,
splitModelName,
} from "./model-select";
@@ -47,11 +57,64 @@ describe("modelKeys", () => {
).toEqual(["openai", "anthropic", "google"]);
});
+ it("sorts numbered OpenCode keys numerically (#2 before #10)", () => {
+ expect(
+ modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]),
+ ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]);
+ });
+
it("is empty for no models", () => {
expect(modelKeys([])).toEqual([]);
});
});
+describe("openCodeNumber", () => {
+ it("parses a numbered OpenCode key", () => {
+ expect(openCodeNumber("opencode-1")).toBe(1);
+ expect(openCodeNumber("opencode-10")).toBe(10);
+ });
+
+ it("returns null for non-openCode / malformed keys", () => {
+ expect(openCodeNumber("umans")).toBeNull();
+ expect(openCodeNumber("opencode")).toBeNull();
+ expect(openCodeNumber("opencode-0")).toBeNull();
+ expect(openCodeNumber("opencode-01")).toBeNull();
+ });
+});
+
+describe("modelKeyLabel", () => {
+ it("labels a numbered OpenCode key as OpenCode #N", () => {
+ expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2");
+ expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10");
+ });
+
+ it("leaves non-OpenCode keys unchanged", () => {
+ expect(modelKeyLabel("umans")).toBe("umans");
+ });
+});
+
+describe("migrateLegacyModelName", () => {
+ it("rewrites opencode/<model> to opencode-1/<model>", () => {
+ expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe(
+ "opencode-1/deepseek-v4-flash",
+ );
+ expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model");
+ });
+
+ it("leaves already-numbered and other-provider names unchanged", () => {
+ expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe(
+ "opencode-1/deepseek-v4-flash",
+ );
+ expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2");
+ expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x");
+ });
+
+ it("is idempotent", () => {
+ const once = migrateLegacyModelName("opencode/deepseek-v4-flash");
+ expect(migrateLegacyModelName(once)).toBe(once);
+ });
+});
+
describe("modelsForKey", () => {
it("returns the model suffixes under a key, in order", () => {
const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
@@ -87,3 +150,66 @@ describe("isVisionModel", () => {
expect(isVisionModel({}, "kimi/k2")).toBe(false);
});
});
+
+describe("named-agent provider helpers", () => {
+ it("providerKeysWithNamedAgents puts the synthetic provider first, then raw keys", () => {
+ expect(providerKeysWithNamedAgents(["openai/gpt-4", "anthropic/claude"])).toEqual([
+ NAMED_AGENTS_PROVIDER,
+ "openai",
+ "anthropic",
+ ]);
+ // Always present even with zero models.
+ expect(providerKeysWithNamedAgents([])).toEqual([NAMED_AGENTS_PROVIDER]);
+ });
+
+ it("namedAgentsProviderLabel + NAMED_AGENTS_PROVIDER are stable", () => {
+ expect(namedAgentsProviderLabel()).toBe("Named Agents");
+ expect(NAMED_AGENTS_PROVIDER).toBe("__named_agents__");
+ });
+
+ it("namedAgentNames returns the display names", () => {
+ expect(namedAgentNames([{ id: "a", name: "Coding", model: "m" }])).toEqual(["Coding"]);
+ });
+
+ it("isNamedAgentSelection is true only for a snapshot whose model equals the selection", () => {
+ expect(
+ isNamedAgentSelection("opencode-1/claude", {
+ namedAgentId: "a",
+ namedAgentName: "C",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ expect(
+ isNamedAgentSelection("other/model", {
+ namedAgentId: "a",
+ namedAgentName: "C",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(false);
+ expect(isNamedAgentSelection("opencode-1/claude", null)).toBe(false);
+ });
+
+ it("namedAgentSelectionLabel renders 'Named Agents -> <name>'", () => {
+ expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding");
+ });
+});
+
+describe("isClaudeSubscriptionModel", () => {
+ it("is true for claude/<model> (the Claude Code subscription credential)", () => {
+ expect(isClaudeSubscriptionModel("claude/claude-opus-4-6")).toBe(true);
+ expect(isClaudeSubscriptionModel("claude/claude-sonnet-4-6")).toBe(true);
+ });
+
+ it("is false for Claude models served through other credentials", () => {
+ expect(isClaudeSubscriptionModel("opencode-1/claude-sonnet-4.5")).toBe(false);
+ expect(isClaudeSubscriptionModel("openrouter/anthropic/claude-opus-4")).toBe(false);
+ expect(isClaudeSubscriptionModel("openai/gpt-4o")).toBe(false);
+ });
+
+ it("is false for a bare key, a lookalike key, or undefined", () => {
+ expect(isClaudeSubscriptionModel("claude")).toBe(false);
+ expect(isClaudeSubscriptionModel("claude/")).toBe(false);
+ expect(isClaudeSubscriptionModel("claude-2/claude-opus-4-6")).toBe(false);
+ expect(isClaudeSubscriptionModel(undefined)).toBe(false);
+ });
+});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index 602e0ef..bebad2c 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string {
/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
const seen = new Set<string>();
- const out: string[] = [];
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
for (const full of models) {
const { key } = splitModelName(full);
- if (!seen.has(key)) {
- seen.add(key);
- out.push(key);
- }
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const n = openCodeNumber(key);
+ if (n !== null) openCodeKeys.push({ key, n });
+ else otherKeys.push(key);
}
- return out;
+ // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed
+ // before other providers (the primary credential in this app). Others keep
+ // first-seen order.
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/**
+ * The stable numeric identity of a numbered OpenCode provider id/key
+ * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a
+ * numbered OpenCode key. Numbers are canonical positive integers with no
+ * leading zeros.
+ */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */
+export function isNumberedOpenCode(id: string): boolean {
+ return openCodeNumber(id) !== null;
+}
+
+/**
+ * The credential key of the Claude Code subscription (the external Claude
+ * extension — `auth-claude` + `provider-anthropic`). The backend registers it
+ * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive
+ * as `claude/<model>`.
+ */
+export const CLAUDE_SUBSCRIPTION_KEY = "claude";
+
+/**
+ * True iff `full` is a model served through the Claude Code subscription
+ * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache
+ * expires after 5 minutes). A bare key with no model is not a model.
+ */
+export function isClaudeSubscriptionModel(full: string | undefined): boolean {
+ if (full === undefined) return false;
+ const { key, model } = splitModelName(full);
+ return key === CLAUDE_SUBSCRIPTION_KEY && model !== "";
+}
+
+/**
+ * A friendly human label for a model key/provider id: `opencode-<n>` →
+ * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what
+ * gets selected/persisted — this only affects display.
+ */
+export function modelKeyLabel(key: string): string {
+ const n = openCodeNumber(key);
+ return n === null ? key : `OpenCode #${n}`;
+}
+
+/**
+ * Migration helper: rewrite a legacy `opencode/<model>` full model name to the
+ * numbered `opencode-1/<model>` namespace. Returns the input unchanged for
+ * anything that is not `opencode/`-prefixed (already-numbered names, other
+ * providers). Idempotent. This is the FE-side persisted-state migration; the
+ * backend performs its own equivalent on its own store.
+ */
+export function migrateLegacyModelName(full: string): string {
+ const legacyPrefix = "opencode/";
+ if (!full.startsWith(legacyPrefix)) return full;
+ const model = full.slice(legacyPrefix.length);
+ return model.length === 0 ? full : `opencode-1/${model}`;
}
/** The model suffixes available under a given key, in order. */
@@ -62,3 +127,73 @@ export function isVisionModel(
): boolean {
return modelInfo[fullName]?.vision === true;
}
+
+// ── Named Agents synthetic provider (plan §6) ────────────────────────────────
+//
+// The regular model picker gains a "Named Agents" provider at the TOP of the
+// provider list. Selecting it + a Named Agent name resolves to that agent's raw
+// model for the conversation AND records a snapshot so the selection survives
+// later edits to the shared preset. These helpers are structural (they take a
+// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the
+// named-agents feature up the chain; the picker binds the real types.
+
+/** A minimal Named Agent shape the synthetic provider needs (structural). */
+export interface NamedAgentLike {
+ readonly id: string;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** A minimal per-conversation Named Agent selection snapshot (structural). */
+export interface NamedAgentSnapshotLike {
+ readonly namedAgentId: string;
+ readonly namedAgentName: string;
+ readonly model: string;
+}
+
+/**
+ * The synthetic "Named Agents" provider id used by the regular model picker
+ * (plan §6). Deliberately a value that can never collide with a real provider
+ * (`<credentialName>`), so the provider dropdown treats it as the first option.
+ */
+export const NAMED_AGENTS_PROVIDER = "__named_agents__";
+
+/** The friendly label of the synthetic Named Agents provider. */
+export function namedAgentsProviderLabel(): string {
+ return "Named Agents";
+}
+
+/**
+ * The provider dropdown options for the picker: the synthetic "Named Agents"
+ * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic
+ * entry stays present even with zero Named Agents (plan §6 shows a disabled
+ * "No named agents configured" model option).
+ */
+export function providerKeysWithNamedAgents(models: readonly string[]): string[] {
+ return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)];
+}
+
+/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */
+export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] {
+ return agents.map((a) => a.name);
+}
+
+/**
+ * Whether a full raw model string corresponds to a Named Agent snapshot —
+ * i.e. the conversation was selected via a Named Agent whose captured raw model
+ * equals `selected`. When true, the picker displays `Named Agents -> <name>`.
+ */
+export function isNamedAgentSelection(
+ selected: string,
+ snapshot: NamedAgentSnapshotLike | null,
+): boolean {
+ return snapshot !== null && snapshot.model === selected;
+}
+
+/**
+ * The snapshot label shown in the model dropdown for a Named Agent selection:
+ * "Named Agents -> <name>". Pure display.
+ */
+export function namedAgentSelectionLabel(name: string): string {
+ return `${namedAgentsProviderLabel()} -> ${name}`;
+}
diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts
new file mode 100644
index 0000000..d7fb0aa
--- /dev/null
+++ b/src/features/chat/pending-inputs.ts
@@ -0,0 +1,12 @@
+import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+
+/** A saved copy, never automatically retransmitted: requestId is not idempotent. */
+export interface PendingInput {
+ readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string };
+ readonly rejection?: string;
+}
+
+export interface PendingInputStorage {
+ load(): readonly PendingInput[];
+ save(inputs: readonly PendingInput[]): void;
+}
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index 2fe10dc..53ac236 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,4 +1,5 @@
import type {
+ ChatQueueCancelMessage,
ChatQueueMessage,
ChatSendMessage,
ConversationHistoryResponse,
@@ -6,12 +7,14 @@ import type {
} from "@dispatch/transport-contract";
/**
- * Injected transport port — sends chat messages to the server. Accepts both
- * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message;
- * auto-starts a turn if idle).
+ * Injected transport port — sends chat messages to the server. Accepts
+ * `chat.send` (start a turn), `chat.queue` (enqueue a steering message;
+ * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued
+ * message by id so it never runs — fire-and-forget, idempotent; the
+ * message-queue surface confirms the removal).
*/
export interface ChatTransport {
- send(msg: ChatSendMessage | ChatQueueMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void;
}
/**
diff --git a/src/features/chat/queued.test.ts b/src/features/chat/queued.test.ts
new file mode 100644
index 0000000..3b579c6
--- /dev/null
+++ b/src/features/chat/queued.test.ts
@@ -0,0 +1,207 @@
+import type { ChatQueuedMessage } from "@dispatch/transport-contract";
+import type { AgentEvent } from "@dispatch/wire";
+import { describe, expect, it, vi } from "vitest";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+const images = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "data:image/png;base64,BBBB" },
+];
+
+function fixture(batched = false) {
+ const transport = createFakeTransport();
+ const history = createFakeHistorySync();
+ let nextId = 0;
+ const scheduled: (() => void)[] = [];
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => `r${++nextId}`,
+ transport: transport.impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ ...(batched ? { scheduleFlush: (flush: () => void) => scheduled.push(flush) } : {}),
+ });
+ function ack(requestId: string | undefined = "r1", conversationId = "c1") {
+ const msg: ChatQueuedMessage = {
+ type: "chat.queued",
+ conversationId,
+ reason: "compaction",
+ messageId: "q1",
+ ...(requestId !== undefined ? { requestId } : {}),
+ };
+ store.handleDelta(msg);
+ }
+ function event(event: AgentEvent) {
+ store.handleDelta({ type: "chat.delta", event });
+ }
+ function start(text?: string, turnId = "t1") {
+ event({ type: "turn-start", conversationId: "c1", turnId });
+ if (text !== undefined) event({ type: "user-message", conversationId: "c1", turnId, text });
+ }
+ return { store, transport, history, scheduled, ack, event, start };
+}
+
+describe("compaction-deferred chat.send", () => {
+ it.each([
+ "hello",
+ "",
+ ])("removes just the accepted local text/images for %j without error, retry or queue sends", (text) => {
+ const f = fixture();
+ f.store.send(text, images);
+ expect(f.store.chunks).toHaveLength(text ? 3 : 2);
+ f.ack();
+ f.ack();
+ expect(f.store.chunks).toEqual([]);
+ expect(f.store.generating).toBe(false);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toHaveLength(1);
+ expect(f.transport.sent[0]).toMatchObject({ requestId: "r1", images, message: text });
+ expect(f.transport.sentQueue).toEqual([]);
+ f.store.dispose();
+ });
+
+ it("uses distinct request identities for identical sends and preserves unrelated content", () => {
+ const f = fixture();
+ f.start("another client's prompt");
+ f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "working" });
+ f.store.send("identical", images);
+ f.store.send("identical", images);
+ expect(f.transport.sent.map((m) => m.requestId)).toEqual(["r1", "r2"]);
+ f.ack("r1");
+ f.ack("r1");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "another client's prompt" },
+ { type: "text", text: "working" },
+ { type: "text", text: "identical" },
+ ...images.map((i) => ({ type: "image", ...i })),
+ ]);
+ f.ack("r2");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "another client's prompt" },
+ { type: "text", text: "working" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it.each([
+ "missing",
+ "unknown",
+ "wrong-conversation",
+ ])("tolerates %s correlation without deleting any echo", (kind) => {
+ const f = fixture();
+ f.store.send("hello", images);
+ f.store.handleDelta({
+ type: "chat.queued",
+ conversationId: kind === "wrong-conversation" ? "c2" : "c1",
+ reason: "compaction",
+ messageId: "q1",
+ ...(kind === "missing" ? {} : { requestId: kind === "unknown" ? "r999" : "r1" }),
+ });
+ expect(f.store.chunks).toHaveLength(3);
+ expect(f.store.error).toBeNull();
+ f.store.dispose();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("preserves a live confirmed prompt before delayed ack (batched=%s)", (batched) => {
+ const f = fixture(batched);
+ f.store.send("hello", images);
+ f.start("hello");
+ f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "answer" });
+ f.ack();
+ f.ack();
+ for (const flush of f.scheduled) flush();
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hello" },
+ { type: "text", text: "answer" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ f.store.dispose();
+ });
+
+ it("turn-start tagging alone does not protect a still-local echo", () => {
+ const f = fixture(true);
+ f.store.send("hello", images);
+ f.start();
+ f.ack();
+ expect(f.store.chunks).toEqual([]);
+ expect(f.store.generating).toBe(true);
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t1", text: "hello" });
+ for (const flush of f.scheduled) flush();
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([{ type: "text", text: "hello" }]);
+ f.store.dispose();
+ });
+
+ it("keeps a combined queue opening and steering; removes only the original individual echoes", () => {
+ const f = fixture(true);
+ f.store.send("one", images);
+ f.store.send("two", images);
+ f.start("one\n\ntwo");
+ f.event({ type: "steering", conversationId: "c1", turnId: "t1", text: "steer" });
+ f.ack("r2");
+ f.ack("r1");
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "one\n\ntwo" },
+ { type: "text", text: "steer" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it.each([
+ "ack-first",
+ "reset-first",
+ "history-first",
+ ])("handles epoch replacement idempotently: %s", async (order) => {
+ const f = fixture();
+ await f.store.load();
+ f.store.send("hello", images);
+ if (order === "ack-first") f.ack();
+ f.history.historyEpoch = 1;
+ f.history.returnChunks = [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } },
+ ];
+ f.store.resync(1);
+ if (order === "reset-first") f.ack();
+ await vi.waitFor(() => expect(f.store.pendingSync).toBe(false));
+ f.start("hello");
+ const before = f.store.chunks;
+ f.ack();
+ f.ack();
+ expect(f.store.chunks).toEqual(before);
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk));
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toHaveLength(1);
+ f.store.dispose();
+ });
+
+ it("keeps normal immediate-send text/image deduplication and authoritative history", async () => {
+ const f = fixture();
+ f.store.send("hello", images);
+ f.start("hello");
+ expect(f.store.chunks).toHaveLength(3);
+ f.history.returnChunks = [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } },
+ ];
+ f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ await vi.waitFor(() => expect(f.store.pendingSync).toBe(false));
+ expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk));
+ expect(f.store.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(f.transport.sent).toHaveLength(1);
+ f.store.dispose();
+ });
+});
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
index 6d409e9..e870bac 100644
--- a/src/features/chat/reasoning-effort.test.ts
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
describe("reasoning-effort helpers", () => {
@@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => {
}
});
});
+
+describe("thinking selection (the separate on/off axis)", () => {
+ it("selectionOptions lists 'off' first, then the ladder (default marked)", () => {
+ const options = selectionOptions();
+ expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length);
+ expect(options[0]?.value).toBe("off");
+ expect(options[0]?.label).toBe("Off");
+ // the rest are the ladder, unchanged from effortOptions()
+ expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
+ expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
+ });
+
+ it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => {
+ expect(isThinkingSelection("off")).toBe(true);
+ for (const level of REASONING_EFFORT_LEVELS) {
+ expect(isThinkingSelection(level)).toBe(true);
+ }
+ expect(isThinkingSelection("banana")).toBe(false);
+ expect(isThinkingSelection("")).toBe(false);
+ expect(isThinkingSelection("OFF")).toBe(false);
+ expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send
+ });
+
+ it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => {
+ // thinking off is a SEPARATE axis: the effort level is irrelevant while off.
+ expect(effectiveSelection("xhigh", false)).toBe("off");
+ expect(effectiveSelection(null, false)).toBe("off");
+ });
+
+ it("effectiveSelection shows the effort level when thinking is on (default)", () => {
+ // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level.
+ expect(effectiveSelection(null, null)).toBe("high"); // default effort
+ expect(effectiveSelection("low", null)).toBe("low");
+ expect(effectiveSelection("max", true)).toBe("max"); // explicitly on
+ });
+});
diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts
index 1eb77b6..39e1c5a 100644
--- a/src/features/chat/reasoning-effort.ts
+++ b/src/features/chat/reasoning-effort.ts
@@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff
return persisted ?? DEFAULT_REASONING_EFFORT;
}
-/** One `<option>` of the selector. */
+/** One `<option>` of the effort ladder. */
export interface EffortOption {
readonly value: ReasoningEffort;
readonly label: string;
@@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult =
export type SaveReasoningEffort = (
level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
+
+// ── Thinking on/off (a SEPARATE axis from the effort level) ─────────────────
+//
+// Per the umans API (and the user's mental model), "thinking off" is NOT a
+// zero-effort level — it is a distinct "disable extended thinking entirely"
+// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch
+// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is
+// preserved across an off→on toggle (turning thinking back on restores the
+// previously-chosen depth). The per-conversation selector CONFLATES the two
+// axes into one `<select>` (UX), but the WIRE keeps them separate.
+//
+// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint +
+// wire types below are the PROPOSED shape; the backend has NOT shipped them yet.
+// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` /
+// `SetReasoningEffortRequest` shape) so the FE is built + tested against the
+// target contract. Re-pin + re-mirror once the backend ships them.
+
+/**
+ * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null
+ * when never set (the server then resolves turns with thinking ON — the
+ * default), `false` when explicitly disabled, `true` when explicitly enabled.
+ */
+export interface ThinkingResponse {
+ readonly conversationId: string;
+ readonly thinking: boolean | null;
+}
+
+/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */
+export interface SetThinkingRequest {
+ readonly thinking: boolean;
+}
+
+/**
+ * The per-conversation selector's value: `"off"` (thinking disabled — the
+ * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is
+ * not a degree of effort, it is the absence of thinking.
+ */
+export type ThinkingSelection = "off" | ReasoningEffort;
+
+/** One `<option>` of the combined selector (off or a level). */
+export interface SelectionOption {
+ readonly value: ThinkingSelection;
+ readonly label: string;
+}
+
+/**
+ * The selector's options: `"off"` first (the separate disable signal), then the
+ * effort ladder with the server default marked `(default)`. A never-set
+ * conversation (thinking on, effort null) reads "high (default)".
+ */
+export function selectionOptions(): readonly SelectionOption[] {
+ return [{ value: "off", label: "Off" }, ...effortOptions()];
+}
+
+/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */
+export function isThinkingSelection(value: string): value is ThinkingSelection {
+ return value === "off" || isReasoningEffort(value);
+}
+
+/**
+ * The selection the per-conversation selector should show as selected: `"off"`
+ * when thinking is explicitly disabled (`persistedThinking === false`), else the
+ * effective effort level. `persistedThinking === null` (never set) ⇒ thinking
+ * ON (the default) ⇒ the effort level is shown — NOT "off".
+ */
+export function effectiveSelection(
+ persistedEffort: ReasoningEffort | null,
+ persistedThinking: boolean | null,
+): ThinkingSelection {
+ if (persistedThinking === false) return "off";
+ return effectiveEffort(persistedEffort);
+}
+
+// ── Injected port for the combined selector (off OR a level) ─────────────────
+
+/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */
+export type ThinkingSelectionSaveResult =
+ | { readonly ok: true; readonly selection: ThinkingSelection }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Persist a thinking selection (consumer-defines-port; the composition root
+ * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this).
+ * - `"off"` → disable thinking (the separate signal); the effort level is left
+ * untouched so an off→on toggle restores it.
+ * - a level → set the effort level AND ensure thinking is ON (the level is
+ * meaningless while thinking is off).
+ */
+export type SaveThinkingSelection = (
+ selection: ThinkingSelection,
+) => Promise<ThinkingSelectionSaveResult | null>;
+
+/** Outcome of `PUT /conversations/:id/thinking`. */
+export type ThinkingSaveResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
diff --git a/src/features/chat/recovery.test.ts b/src/features/chat/recovery.test.ts
new file mode 100644
index 0000000..4b46288
--- /dev/null
+++ b/src/features/chat/recovery.test.ts
@@ -0,0 +1,529 @@
+import type {
+ ChatResyncRequiredMessage,
+ ConversationHistoryResponse,
+} from "@dispatch/transport-contract";
+import type { AgentEvent, StepId, StoredChunk, TurnEndedEvent } from "@dispatch/wire";
+import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createIdbChunkStore } from "../../adapters/idb";
+import { createConversationCache } from "../conversation-cache";
+import type { HistorySync } from "./ports";
+import { createChatStore } from "./store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "./test-helpers";
+
+const step = "t1#0" as StepId;
+const opening: StoredChunk = {
+ seq: 1,
+ role: "user",
+ turnId: "t1",
+ chunk: { type: "text", text: "prompt" },
+};
+const call: StoredChunk = {
+ seq: 3,
+ role: "assistant",
+ turnId: "t1",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ stepId: step,
+ input: { command: "full input" },
+ },
+};
+const result: StoredChunk = {
+ seq: 4,
+ role: "tool",
+ turnId: "t1",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ stepId: step,
+ content: "full output",
+ isError: false,
+ },
+};
+const committed: StoredChunk[] = [
+ opening,
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "complete text" } },
+ call,
+ result,
+];
+
+function fixture(historySync?: HistorySync, batched = false) {
+ const history = createFakeHistorySync();
+ const transport = createFakeTransport();
+ const cache = createFakeCache();
+ const scheduled: (() => void)[] = [];
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => "r1",
+ historySync: historySync ?? history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ transport: transport.impl,
+ ...(batched ? { scheduleFlush: (fn: () => void) => scheduled.push(fn) } : {}),
+ });
+ const event = (event: AgentEvent) => store.handleDelta({ type: "chat.delta", event });
+ const notice = (
+ turnId: string | undefined = "t1",
+ reason: ChatResyncRequiredMessage["reason"] = "replay-limit",
+ ) =>
+ store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason,
+ ...(turnId !== undefined ? { turnId } : {}),
+ });
+ const end = (reason: TurnEndedEvent["reason"] = "sealed", turnId = "t1") =>
+ event({ type: "turn-ended", conversationId: "c1", turnId, reason });
+ const text = (delta: string, turnId = "t1") =>
+ event({ type: "text-delta", conversationId: "c1", turnId, delta });
+ return { store, history, transport, cache, scheduled, event, notice, end, text };
+}
+
+async function settled(store: ReturnType<typeof createChatStore>) {
+ for (let i = 0; i < 80 && store.pendingSync; i++) await Promise.resolve();
+ expect(store.pendingSync).toBe(false);
+}
+function response(chunks: readonly StoredChunk[], historyEpoch = 0): ConversationHistoryResponse {
+ return { chunks, historyEpoch, latestSeq: chunks.at(-1)?.seq ?? 0 };
+}
+function deferred() {
+ let resolve!: (value: ConversationHistoryResponse) => void;
+ const promise = new Promise<ConversationHistoryResponse>((r) => {
+ resolve = r;
+ });
+ return { promise, resolve };
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe("omitted chat recovery", () => {
+ it.each([
+ false,
+ true,
+ ])("marks omitted replay active without content and recovers full HTTP payload (batched=%s)", async (batched) => {
+ vi.useFakeTimers();
+ const f = fixture(undefined, batched);
+ f.text("partial");
+ f.notice();
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual([{ type: "text", text: "partial" }]);
+ await settled(f.store);
+ // No initial commit yet. A tool result follows an OMITTED call input.
+ f.event({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "short replay",
+ isError: false,
+ });
+ f.history.returnChunks = committed;
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toEqual([
+ expect.objectContaining({ chunk: call.chunk, provisional: false }),
+ ]);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1);
+ expect(f.store.chunks.find((r) => r.chunk.type === "tool-result")?.chunk).toEqual(result.chunk);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.error).toBeNull();
+ expect(f.transport.sent).toEqual([]);
+ f.store.dispose();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("coalesces repeat notices and polls after step notification races persistence, without parallel requests", async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => {
+ calls++;
+ return calls === 1 ? pending.promise : response(committed);
+ });
+ f.notice();
+ for (let i = 0; i < 20; i++) f.notice();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ expect(calls).toBe(1);
+ f.event({ type: "step-complete", conversationId: "c1", turnId: "t1", stepId: step });
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(1);
+ pending.resolve(response([]));
+ await settled(f.store);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(999);
+ expect(calls).toBe(1);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(calls).toBe(2);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk));
+ f.store.dispose();
+ });
+
+ it.each([
+ "sealed",
+ "aborted",
+ "error",
+ "rejected",
+ ] as const)("final-syncs %s behind an in-flight fetch and preserves immediate queued carry", async (reason) => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)));
+ f.notice();
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ f.text("old tail");
+ f.end(reason);
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued input" });
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" });
+ f.text("carried tail", "t2");
+ pending.resolve(response([opening]));
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "queued input" },
+ { type: "text", text: "carried tail" },
+ ]);
+ expect(f.store.chunks.filter((r) => r.turnId === "t1").every((r) => !r.provisional)).toBe(true);
+ await vi.advanceTimersByTimeAsync(5000);
+ expect(calls).toBe(2);
+ f.end("sealed", "t2");
+ expect(f.store.generating).toBe(false);
+ f.store.dispose();
+ });
+
+ it.each([
+ false,
+ true,
+ ])("notice during %s-cache load requests one rerun, preserving current window", async (warm) => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async (_id, _since, window) => {
+ calls++;
+ if (!warm) expect(window?.limit).toBe(192);
+ return calls === 1 ? pending.promise : response([opening]);
+ });
+ if (warm) f.cache.store.set("c1", [opening]);
+ const load = f.store.load();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ f.notice();
+ pending.resolve(response([opening]));
+ await load;
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.chunks).toHaveLength(1);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.store.dispose();
+ });
+
+ it("payload notice after a completed HTTP load preserves live tail, unrelated turn and tool progress", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ await f.store.load();
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "other" });
+ f.text("unrelated", "other");
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" });
+ f.text("partial text");
+ f.event({
+ type: "tool-call",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: "short" },
+ });
+ f.event({
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ stepId: step,
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: "short",
+ isError: false,
+ });
+ f.text("complete text"); // same payload as earlier step; NOT the same identity
+ f.event({
+ type: "tool-output",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "next",
+ data: "progress",
+ stream: "stdout",
+ });
+ f.history.returnChunks = committed;
+ f.notice("t1", "payload-limit");
+ await settled(f.store);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "unrelated" },
+ { type: "text", text: "complete text" },
+ ]);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toHaveLength(1);
+ expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1);
+ f.store.dispose();
+ });
+
+ it("bounds epoch churn during recovery and cancels its retry timer on disposal", async () => {
+ vi.useFakeTimers();
+ let calls = 0;
+ const f = fixture(async () => {
+ f.store.invalidateHistory(++calls);
+ return response([], calls - 1);
+ });
+ f.notice();
+ await settled(f.store);
+ expect(calls).toBe(3);
+ expect(f.store.error).toContain("sync paused");
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(3);
+ f.store.dispose();
+ await vi.advanceTimersByTimeAsync(10000);
+ expect(calls).toBe(3);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("ignores other conversations and disposed notices; accepts a notice without turnId", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "other",
+ reason: "payload-limit",
+ });
+ expect(f.store.recovering).toBe(false);
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "payload-limit",
+ });
+ await settled(f.store);
+ expect(f.store.generating).toBe(false);
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" });
+ f.store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ reason: "payload-limit",
+ });
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(true);
+ f.end("rejected");
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ f.store.dispose();
+ f.notice();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
+
+it("recovers a windowed rollback with real IndexedDB cache while preserving lifecycle evidence", async () => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.commit(
+ "c1",
+ [{ seq: 1, role: "assistant", chunk: { type: "text", text: "old epoch" } }],
+ 5,
+ );
+ const history = createFakeHistorySync();
+ history.historyEpoch = 5;
+ const timers = new Map<object, () => void>();
+ const store = createChatStore({
+ conversationId: "c1",
+ createRequestId: () => "r",
+ cache,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ transport: createFakeTransport().impl,
+ chatLimit: 10,
+ recoveryTimer: {
+ schedule(fn) {
+ const key = {};
+ timers.set(key, fn);
+ return key;
+ },
+ cancel(key) {
+ timers.delete(key as object);
+ },
+ },
+ });
+ try {
+ await store.load();
+ history.historyEpoch = 0;
+ history.returnChunks = Array.from({ length: 20 }, (_, i) => ({
+ seq: i + 1,
+ role: "assistant" as const,
+ turnId: "t1",
+ chunk: { type: "text" as const, text: `restored-${i}` },
+ }));
+ const before = history.calls.length;
+ store.handleDelta({
+ type: "chat.resync-required",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(history.calls.length - before).toBe(2);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(await cache.historyEpoch("c1")).toBe(0);
+ expect(store.chunks.map((c) => c.seq)).toEqual([14, 15, 16, 17, 18, 19, 20]);
+ expect(store.hasEarlier).toBe(true);
+ expect(store.generating).toBe(true);
+ expect(timers.size).toBe(1);
+ store.handleDelta({
+ type: "chat.delta",
+ event: { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" },
+ });
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(store.generating).toBe(false);
+ expect(timers.size).toBe(0);
+ expect(store.hasEarlier).toBe(true);
+ } finally {
+ store.dispose();
+ }
+});
+
+it("ignores an unrelated terminal and stays recovering through done/seal until its actual terminal", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.notice();
+ await settled(f.store);
+ f.end("aborted", "unrelated");
+ f.event({ type: "done", conversationId: "c1", turnId: "t1", reason: "aborted" });
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" });
+ await settled(f.store);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ f.end("aborted");
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("makes a post-terminal request even when the terminal races the third bounded epoch attempt", async () => {
+ vi.useFakeTimers();
+ const third = deferred();
+ let calls = 0;
+ const f = fixture(async () => {
+ calls++;
+ if (calls < 3) {
+ f.store.invalidateHistory(calls);
+ return response([], calls - 1);
+ }
+ return calls === 3 ? third.promise : response(committed, 2);
+ });
+ f.notice();
+ for (let i = 0; i < 60 && calls < 3; i++) await Promise.resolve();
+ expect(calls).toBe(3);
+ f.end("error");
+ third.resolve(response([], 2));
+ await settled(f.store);
+ expect(calls).toBe(4);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk));
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("retains active recovery across a compaction epoch refresh and cancels it on reconnect/dispose", async () => {
+ vi.useFakeTimers();
+ const f = fixture();
+ f.notice();
+ await settled(f.store);
+ f.history.historyEpoch = 1;
+ f.store.resync(1);
+ await settled(f.store);
+ expect(f.store.recovering).toBe(true);
+ expect(f.store.generating).toBe(true);
+ expect(vi.getTimerCount()).toBe(1);
+ f.store.resync();
+ await settled(f.store);
+ expect(f.store.recovering).toBe(false);
+ expect(f.store.generating).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("flushes a batched aborted terminal plus carried turn before applying an older HTTP response", async () => {
+ vi.useFakeTimers();
+ const pending = deferred();
+ let calls = 0;
+ const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)), true);
+ f.notice();
+ for (let i = 0; i < 15; i++) await Promise.resolve();
+ f.text("old partial");
+ f.end("aborted");
+ f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued" });
+ f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" });
+ f.text("live carried output", "t2");
+ pending.resolve(response([opening]));
+ await settled(f.store);
+ expect(calls).toBe(2);
+ expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([
+ { type: "text", text: "queued" },
+ { type: "text", text: "live carried output" },
+ ]);
+ expect(f.store.generating).toBe(true);
+ expect(f.store.recovering).toBe(false);
+ expect(vi.getTimerCount()).toBe(0);
+ f.store.dispose();
+});
+
+it("does not truncate a payload larger than the WS budget recovered over HTTP", async () => {
+ vi.useFakeTimers();
+ const large = "x".repeat(4 * 1024 * 1024 + 1);
+ const full: StoredChunk[] = [
+ {
+ ...call,
+ chunk: {
+ ...call.chunk,
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "run_shell",
+ input: { command: large },
+ stepId: step,
+ },
+ },
+ {
+ ...result,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "run_shell",
+ content: large,
+ isError: false,
+ stepId: step,
+ },
+ },
+ ];
+ const f = fixture();
+ f.history.returnChunks = full;
+ f.notice();
+ await settled(f.store);
+ expect(f.store.chunks).toHaveLength(2);
+ expect(f.store.chunks.map((r) => r.chunk)).toEqual(full.map((r) => r.chunk));
+ expect(f.transport.sent).toEqual([]);
+ f.store.dispose();
+});
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 9911438..dacbe06 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,8 +1,13 @@
import type {
+ ChatAcceptedMessage,
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueueCancelMessage,
+ ChatQueuedMessage,
ChatQueueMessage,
+ ChatResyncRequiredMessage,
ChatSendMessage,
+ ChatSubscriptionRejectedMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
@@ -13,7 +18,9 @@ import {
foldEvent,
initialState,
initialWindowSize,
+ markHistoryRecovering,
normalizeChatLimit,
+ removeLocalEcho,
restoreEarlier,
selectChunks,
selectGenerating,
@@ -24,19 +31,28 @@ import {
unloadCount,
windowTranscript,
} from "../../core/chunks";
-import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
+import type { CacheTimerState, MetricsState, TurnMetricsEntry } from "../../core/metrics";
import {
applyDurableMetrics,
+ foldCacheTimerEvent,
foldMetricsEvent,
+ initialCacheTimerState,
initialMetricsState,
+ seedTurnGap,
selectCurrentContextSize,
selectOrderedTurnMetrics,
+ stopTurnGap,
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
+import type { NamedAgentSelectionSnapshot } from "../named-agents";
+import type { PendingInput, PendingInputStorage } from "./pending-inputs";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
+ readonly pendingInputStorage?: PendingInputStorage;
readonly conversationId: string;
+ /** Injected unique client-local correlation token for each new send. */
+ readonly createRequestId: () => string;
readonly model?: string;
readonly transport: ChatTransport;
readonly historySync: HistorySync;
@@ -67,9 +83,27 @@ export interface ChatStoreDependencies {
* `reportError` → full-screen error modal. Absent → errors are logged only.
*/
readonly onError?: (context: string, err: unknown) => void;
+ /**
+ * Schedules a deferred flush of buffered live events (e.g. once per animation
+ * frame). When provided, `handleDelta` QUEUES events and folds the whole batch
+ * in one pass on the scheduled callback — so a burst (the late-join replay on
+ * opening a conversation mid-turn, or a fast stream on a slow device) costs ONE
+ * derivation + render instead of one per WS frame. Absent → every event is
+ * folded synchronously (the original behavior; used by tests).
+ */
+ readonly scheduleFlush?: (flush: () => void) => void;
+ /** Wall clock (epoch ms) — times the cache-timer gaps. Absent → `Date.now`. */
+ readonly now?: () => number;
+ /** Recovery polling clock; browser effects are replaceable by deterministic tests. */
+ readonly recoveryTimer?: {
+ schedule(callback: () => void, delayMs: number): unknown;
+ cancel(handle: unknown): void;
+ };
}
export interface ChatStore {
+ readonly pendingInputs: readonly PendingInput[];
+ discardPendingInput(requestId: string): void;
readonly messages: readonly ChatMessage[];
readonly chunks: readonly RenderedChunk[];
readonly turnMetrics: readonly TurnMetricsEntry[];
@@ -96,6 +130,8 @@ export interface ChatStore {
* to the newest attempt + delay; cleared when content resumes or the turn ends.
*/
readonly providerRetry: TurnProviderRetryEvent | null;
+ /** Omitted live payloads may not exist in HTTP history until step persistence. */
+ readonly recovering: boolean;
readonly pendingSync: boolean;
readonly error: string | null;
readonly model: string | undefined;
@@ -111,7 +147,29 @@ export interface ChatStore {
* a trim (see `TranscriptState.hiddenThinkingCount`).
*/
readonly thinkingKeyBase: number;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ /**
+ * The cache timer (see `core/metrics/cache-timer.ts`): the running gap
+ * between model requests (a step's tools executing, or the wait after a turn
+ * ended) plus the stamped durations of finished gaps, keyed by `stepId` /
+ * `turnId`. Timed with the local clock from live events; in-memory only (a
+ * reload keeps just a turn gap seeded via `noteLastActivity`).
+ */
+ readonly cacheTimer: CacheTimerState;
+ /**
+ * Seed the turn gap from an authoritative activity timestamp (the
+ * conversation list's `lastActivityAt`) — call only for an IDLE conversation.
+ * Ignored while a turn is generating; a running turn gap keeps the later start.
+ */
+ noteLastActivity(at: number): void;
+ handleDelta(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void;
/**
* Send a user message (start a turn via `chat.send`). Optimistically echoes
* the text + any `images` as provisional user chunks (`[text, image, …]` in
@@ -128,11 +186,32 @@ export interface ChatStore {
* auto-starts a turn with the message as its opening prompt (equivalent to
* `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
* pending message until drain; the `steering` event places it in the
- * transcript. `text` must be non-empty (the server 400/errors otherwise).
+ * transcript. Empty `text` is allowed when images are supplied. WS delivery
+ * is fire-and-forget; transport send is not a persistence acknowledgement.
*/
- queueMessage(text: string): void;
+ queueMessage(text: string, images?: readonly ImageInput[]): void;
+ /**
+ * Cancel (remove) a single queued steering message by id so it never runs
+ * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is
+ * confirmed by the `message-queue` SURFACE updating (the cancelled message
+ * leaves the snapshot); a cancel of an already-drained / unknown message is a
+ * silent server-side no-op. The caller optimistically hides the row; the
+ * surface update reconciles. `messageId` is the stable `QueuedMessage.id`
+ * the queue surface snapshot carries. No transcript change — a cancelled
+ * message is never delivered as steering.
+ */
+ cancelQueuedMessage(messageId: string): void;
setModel(model: string): void;
/**
+ * Set (or clear, with null) the Named Agent selection snapshot this chat sends
+ * with `chat.send` (plan §7). The backend persists it on the conversation at
+ * creation so it freezes the friendly id/name + resolved model — later edits to
+ * the Named Agent do not change this conversation's selection. The raw model
+ * (already set via `setModel`) is sent alongside; the snapshot is display/identity
+ * metadata that preserves HOW it was selected.
+ */
+ setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void;
+ /**
* Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
* Lowering it unloads older committed chunks (deferred via the gate while the
* reader is scrolled up, catching up on the next mutation). Raising it
@@ -158,20 +237,165 @@ export interface ChatStore {
* running, the server's post-subscribe replay re-asserts `generating`. The
* app store pairs this with a `chat.subscribe` for the conversation.
*/
- resync(): void;
+ resync(historyEpoch?: number): void;
+ /** Discard pre-replacement transcript state without loading an unseen conversation. */
+ invalidateHistory(historyEpoch: number): void;
dispose(): void;
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
+ let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []);
+ function savePendingInputs(next: readonly PendingInput[]): void {
+ deps.pendingInputStorage?.save(next);
+ pendingInputs = next;
+ }
+ function confirmInput(requestId: string): void {
+ savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId));
+ }
let transcript = $state<TranscriptState>(initialState());
let metrics = $state<MetricsState>(initialMetricsState());
let _pendingSync = $state(false);
let _error = $state<string | null>(null);
let _model = $state<string | undefined>(deps.model);
+ let _namedAgent = $state<NamedAgentSelectionSnapshot | null>(null);
+ let cacheTimer = $state<CacheTimerState>(initialCacheTimerState());
+ const now = deps.now ?? Date.now;
let disposed = false;
+ let rerunRequested = false;
+ let historyEpoch: number | undefined;
+ let revision = 0;
+ let reloadWindow = false;
+ let earliestSeq = $state<number | null>(null);
+ let recoveringTurnId: string | undefined;
+ let recovering = $state(false);
+ const finishedRecoveryTurns = new Set<string>();
+ let finalSyncRequested = false;
+ let recoveryPaused = false;
+ const timer = deps.recoveryTimer ?? {
+ schedule: (callback: () => void, delay: number) => setTimeout(callback, delay),
+ cancel: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>),
+ };
+ let recoveryTimer: unknown;
+
+ function cancelRecoveryTimer(): void {
+ if (recoveryTimer !== undefined) timer.cancel(recoveryTimer);
+ recoveryTimer = undefined;
+ }
+
+ function scheduleRecovery(): void {
+ if (disposed || !recovering || recoveryPaused || recoveryTimer !== undefined) return;
+ recoveryTimer = timer.schedule(() => {
+ recoveryTimer = undefined;
+ flushEvents();
+ void syncTail(initialWindowSize(chatLimit));
+ }, 1000);
+ }
+
+ function resetTranscript(epoch: number): void {
+ historyEpoch = epoch;
+ revision++;
+ reloadWindow = true;
+ transcript = initialState();
+ if (recoveringTurnId !== undefined)
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ earliestSeq = null;
+ pendingEvents = [];
+ }
+ function hasEarlier(): boolean {
+ return selectHasEarlier(transcript, earliestSeq);
+ }
+
+ /** The most recent turn's id (resolves a seeded turn gap when it is stamped). */
+ function lastTurnIdOf(m: MetricsState): string | undefined {
+ const entries = selectOrderedTurnMetrics(m);
+ return entries[entries.length - 1]?.turnId;
+ }
let chatLimit = normalizeChatLimit(deps.chatLimit);
+ // Memoized projections, keyed by state-object identity (the reducers are
+ // immutable, so identity == content). The getters are read several times per
+ // render (ChatPane + ChatView); returning the SAME array for an unchanged
+ // state keeps downstream `$derived`s (grouping, metrics placement) from
+ // recomputing on every read. Plain closures, not `$derived` — a store factory
+ // may run inside a component effect, which would own (and later destroy) it.
+ const memo = <S, R>(fn: (s: S) => R): ((s: S) => R) => {
+ let lastIn: S | undefined;
+ let lastOut: R | undefined;
+ return (s) => {
+ if (lastOut === undefined || s !== lastIn) {
+ lastIn = s;
+ lastOut = fn(s);
+ }
+ return lastOut;
+ };
+ };
+ const chunksOf = memo(selectChunks);
+ const turnMetricsOf = memo(selectOrderedTurnMetrics);
+
+ // Buffered live events awaiting a scheduled flush (see `scheduleFlush`).
+ let pendingEvents: ChatDeltaMessage["event"][] = [];
+ let flushScheduled = false;
+
+ /** Fold every buffered event in one pass, then trim + sync once. */
+ function flushEvents(): void {
+ flushScheduled = false;
+ if (disposed || pendingEvents.length === 0) return;
+ const batch = pendingEvents;
+ pendingEvents = [];
+ applyEvents(batch);
+ }
+
+ function applyEvents(events: readonly ChatDeltaMessage["event"][]): void {
+ let t = transcript;
+ let m = metrics;
+ let sealed = false;
+ let failed = false;
+ let recoveryEnded = false;
+ let c = cacheTimer;
+ const at = now();
+ for (const event of events) {
+ t = foldEvent(t, event);
+ m = foldMetricsEvent(m, event);
+ const mNow = m;
+ c = foldCacheTimerEvent(c, event, at, () => lastTurnIdOf(mNow));
+ if (
+ event.type === "turn-ended" &&
+ recovering &&
+ (recoveringTurnId === undefined || event.turnId === recoveringTurnId)
+ ) {
+ finishedRecoveryTurns.add(event.turnId);
+ finalSyncRequested = true;
+ recoveringTurnId = undefined;
+ recovering = false;
+ recoveryEnded = true;
+ cancelRecoveryTimer();
+ }
+ if (event.type === "turn-sealed") sealed = true;
+ else if (event.type === "error") failed = true;
+ }
+ transcript = t;
+ metrics = m;
+ cacheTimer = c;
+ maybeTrim();
+ if (recovering && recoveringTurnId !== undefined) {
+ // done/error/seal can precede persistence and the real terminal. Keep
+ // lifecycle evidence until turn-ended, not merely until a content event.
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ }
+ if (recoveryEnded) {
+ void syncTail(initialWindowSize(chatLimit));
+ void syncMetrics();
+ } else if (sealed) {
+ void syncTail();
+ void syncMetrics();
+ } else if (failed) {
+ // A turn that FAILS never seals, but the backend still persists the
+ // metrics it accumulated — pull them so the turn gets its row.
+ void syncMetrics();
+ }
+ }
+
/**
* Enforce the chat limit after a transcript mutation — unless the injected
* gate says the reader is scrolled up (then defer; the next mutation retries
@@ -191,27 +415,142 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* between the cache and the fetched window.
*/
async function syncTail(coldLimit?: number): Promise<void> {
- if (disposed || _pendingSync) return;
+ if (disposed) return;
+ if (_pendingSync) {
+ rerunRequested = true;
+ return;
+ }
_pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
- const res = await deps.historySync(deps.conversationId, since, window);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- maybeTrim();
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
+ // One request plus at most two sequential reruns. Continued churn waits for
+ // the next reconnect/seal/explicit resync, never a microtask refetch loop.
+ let attempts = 0;
+ do {
+ attempts++;
+ rerunRequested = false;
+ try {
+ const currentRevision = revision;
+ let cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (historyEpoch !== undefined && cachedEpoch !== historyEpoch) {
+ await deps.cache.reset(deps.conversationId, historyEpoch);
+ cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ }
+ const since =
+ reloadWindow || cachedEpoch === undefined
+ ? 0
+ : await deps.cache.sinceSeq(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision) {
+ rerunRequested = true;
+ continue;
+ }
+ const limit =
+ since === 0
+ ? reloadWindow || recovering || finishedRecoveryTurns.size > 0
+ ? initialWindowSize(chatLimit)
+ : coldLimit
+ : undefined;
+ const finishedAtRequest = [...finishedRecoveryTurns];
+ finalSyncRequested = false;
+ const res = await deps.historySync(
+ deps.conversationId,
+ since,
+ limit === undefined ? undefined : { limit },
+ );
+ if (disposed) break;
+ if (revision !== currentRevision) {
+ rerunRequested = true;
+ continue;
+ }
+ const mismatch =
+ (cachedEpoch !== undefined && cachedEpoch !== res.historyEpoch) ||
+ (historyEpoch !== undefined && historyEpoch !== res.historyEpoch);
+ if (mismatch && since !== 0) {
+ // A cursor response cannot replace a different history. Confirm with
+ // a new full-window request; don't mistake a lower epoch for staleness.
+ reloadWindow = true;
+ rerunRequested = true;
+ continue;
+ }
+ let merged: readonly StoredChunk[];
+ if (mismatch || cachedEpoch === undefined) {
+ const replaced = await deps.cache.replace(
+ deps.conversationId,
+ res.chunks,
+ res.historyEpoch,
+ cachedEpoch,
+ );
+ const replacedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision || !replaced || replacedEpoch !== res.historyEpoch) {
+ rerunRequested = true;
+ continue;
+ }
+ if (mismatch) resetTranscript(res.historyEpoch);
+ merged = res.chunks;
+ } else {
+ merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch);
+ const committedEpoch = await deps.cache.historyEpoch(deps.conversationId);
+ if (disposed) break;
+ if (revision !== currentRevision || committedEpoch !== res.historyEpoch) {
+ rerunRequested = true;
+ continue;
+ }
+ }
+ reloadWindow = false;
+ historyEpoch = res.historyEpoch;
+ if (since === 0 && (limit === undefined || res.chunks.length < limit))
+ earliestSeq = merged[0]?.seq ?? 0;
+ flushEvents();
+ transcript = applyHistory(transcript, merged, finishedAtRequest);
+ for (const turnId of finishedAtRequest) finishedRecoveryTurns.delete(turnId);
+ maybeTrim();
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ } while (rerunRequested && !disposed && attempts < 3);
+ if (rerunRequested && !disposed) {
+ recoveryPaused = true;
+ cancelRecoveryTimer();
+ _error = "Conversation history changed repeatedly; sync paused until the next refresh.";
+ }
+ _pendingSync = false;
+ // Schedule AFTER the bounded sync burst. No interval can queue unbounded
+ // reruns behind a slow request; repeated notices coalesce on the same clock.
+ scheduleRecovery();
+ // A terminal landing during the third (bounded epoch) attempt still owns
+ // one post-terminal request. It is a new external trigger, not an epoch
+ // retry loop; consume the flag before starting its bounded burst.
+ if (finalSyncRequested && !disposed) {
+ finalSyncRequested = false;
+ void syncTail(initialWindowSize(chatLimit));
}
}
- async function syncMetrics(): Promise<void> {
+ let pendingMetrics: Promise<void> | undefined;
+ let rerunMetrics = false;
+ function syncMetrics(): Promise<void> {
+ if (pendingMetrics !== undefined) {
+ rerunMetrics = true;
+ return pendingMetrics;
+ }
+ pendingMetrics = (async () => {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ rerunMetrics = false;
+ await fetchMetrics();
+ if (!rerunMetrics || disposed) break;
+ }
+ })().finally(() => {
+ pendingMetrics = undefined;
+ });
+ return pendingMetrics;
+ }
+
+ async function fetchMetrics(): Promise<void> {
if (disposed) return;
try {
const res = await deps.metricsSync(deps.conversationId);
+ if (disposed) return;
metrics = applyDurableMetrics(metrics, res.turns);
} catch (err) {
// Metrics fetch failure must not block history sync or throw;
@@ -229,14 +568,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* newest `count` of them). Shared by `showEarlier` and the raise-refill.
*/
async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
+ const currentRevision = revision;
let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
+ if (disposed || revision !== currentRevision) return [];
const oldestKnown = earlier[0]?.seq ?? oldest;
- if (earlier.length < want && oldestKnown > 1) {
+ if (earlier.length < want && (earliestSeq === null || oldestKnown > earliestSeq)) {
const res = await deps.historySync(deps.conversationId, 0, {
beforeSeq: oldestKnown,
limit: want - earlier.length,
});
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ if (disposed || revision !== currentRevision) return [];
+ if (res.historyEpoch !== historyEpoch) {
+ // Confirm a backfill mismatch with a fresh full window as well. Bump
+ // the local revision so an overlapping old tail cannot be applied.
+ revision++;
+ reloadWindow = true;
+ await syncTail(initialWindowSize(chatLimit));
+ return [];
+ }
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch);
+ if (disposed || revision !== currentRevision) return [];
+ if (res.chunks.length < want - earlier.length) earliestSeq = merged[0]?.seq ?? 0;
earlier = merged.filter((c) => c.seq < oldest);
}
return earlier;
@@ -246,15 +598,15 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
* Refill toward the fresh-load window after a limit RAISE: pull older
* history (cache first, then server) so the loaded set grows to match what a
* fresh `load()` would show at the new limit. No-op when already at the
- * origin (seq 1) or already within the window. `restoreEarlier` re-derives
+ * known history boundary or already within the window. `restoreEarlier` re-derives
* the window start at apply time, so a delta landing during the await can't
* corrupt the merge. NOT gated (refilling prepends above the viewport; the
* caller preserves scroll position).
*/
async function refill(): Promise<void> {
if (disposed) return;
+ if (!hasEarlier()) return;
const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
const want = initialWindowSize(chatLimit) - transcript.committed.length;
if (want <= 0) return;
try {
@@ -268,14 +620,18 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
return {
+ get pendingInputs() {
+ return pendingInputs;
+ },
+ discardPendingInput: confirmInput,
get messages(): readonly ChatMessage[] {
return selectMessages(transcript);
},
get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
+ return chunksOf(transcript);
},
get turnMetrics(): readonly TurnMetricsEntry[] {
- return selectOrderedTurnMetrics(metrics);
+ return turnMetricsOf(metrics);
},
get currentContextSize(): number | undefined {
return selectCurrentContextSize(metrics);
@@ -286,6 +642,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
get providerRetry(): TurnProviderRetryEvent | null {
return selectProviderRetry(transcript);
},
+ get recovering(): boolean {
+ return recovering;
+ },
get pendingSync(): boolean {
return _pendingSync;
},
@@ -296,55 +655,164 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
return _model;
},
get hasEarlier(): boolean {
- return selectHasEarlier(transcript);
+ return hasEarlier();
},
get thinkingKeyBase(): number {
return transcript.hiddenThinkingCount;
},
+ get cacheTimer(): CacheTimerState {
+ return cacheTimer;
+ },
+
+ noteLastActivity(at: number): void {
+ if (selectGenerating(transcript)) return;
+ cacheTimer = seedTurnGap(cacheTimer, at);
+ },
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ handleDelta(
+ msg:
+ | ChatAcceptedMessage
+ | ChatSubscriptionRejectedMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ChatQueuedMessage
+ | ChatResyncRequiredMessage,
+ ): void {
+ if (disposed) return;
+ if (msg.type === "chat.accepted") {
+ if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId);
+ return;
+ }
+ if (msg.type === "chat.subscription-rejected") {
+ if (msg.conversationId === deps.conversationId) {
+ _error =
+ "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below.";
+ void syncTail();
+ }
+ return;
+ }
+ if (msg.type === "chat.resync-required") {
+ if (msg.conversationId !== deps.conversationId) return;
+ flushEvents();
+ const target =
+ msg.turnId ?? (transcript.generating ? transcript.currentTurnId : null) ?? undefined;
+ // Oversized status packets need not identify an active turn. Refresh
+ // once, but do not invent generation or poll an idle conversation.
+ if (target === undefined) {
+ void syncTail(initialWindowSize(chatLimit));
+ return;
+ }
+ const first = !recovering || recoveryPaused;
+ recoveryPaused = false;
+ recovering = true;
+ recoveringTurnId = target;
+ if (recoveringTurnId !== undefined)
+ transcript = markHistoryRecovering(transcript, recoveringTurnId);
+ // Immediate first handoff, including a coalesced rerun behind a cold
+ // load. Repeats wait for the existing fetch/one-second timer.
+ if (first) void syncTail(initialWindowSize(chatLimit));
+ return;
+ }
if (msg.type === "chat.error") {
if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
return;
}
_error = msg.message;
+ if (msg.requestId !== undefined) {
+ savePendingInputs(
+ pendingInputs.map((input) =>
+ input.request.requestId === msg.requestId
+ ? { ...input, rejection: msg.message }
+ : input,
+ ),
+ );
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
+ return;
+ }
+ if (msg.type === "chat.queued") {
+ if (disposed || msg.conversationId !== deps.conversationId) return;
+ // A queued live opening may already have confirmed the echoed text.
+ // Fold it first; turn-start alone must not protect still-local artifacts.
+ flushEvents();
+ if (msg.requestId !== undefined) {
+ confirmInput(msg.requestId);
+ transcript = removeLocalEcho(transcript, msg.requestId);
+ }
return;
}
if (msg.event.conversationId !== deps.conversationId) {
return;
}
- transcript = foldEvent(transcript, msg.event);
- metrics = foldMetricsEvent(metrics, msg.event);
- maybeTrim();
- if (transcript.sealedTurnId !== null) {
- void syncTail();
- void syncMetrics();
+ if (deps.scheduleFlush === undefined) {
+ applyEvents([msg.event]);
+ return;
+ }
+ pendingEvents.push(msg.event);
+ if (!flushScheduled) {
+ flushScheduled = true;
+ deps.scheduleFlush(flushEvents);
}
},
send(text: string, images?: readonly ImageInput[]): void {
- transcript = appendUserMessage(transcript, text, images);
- maybeTrim();
- const msg: ChatSendMessage = {
+ flushEvents();
+ // Sending ends the wait since the last turn: stamp it on that turn.
+ cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics));
+ const requestId = deps.createRequestId();
+ const msg: ChatSendMessage & { requestId: string } = {
type: "chat.send",
+ requestId,
conversationId: deps.conversationId,
message: text,
...(_model !== undefined ? { model: _model } : {}),
+ ...(_namedAgent !== null ? { namedAgent: _namedAgent } : {}),
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been sent", error);
+ throw error;
+ }
+ transcript = appendUserMessage(transcript, text, images, requestId);
+ maybeTrim();
deps.transport.send(msg);
},
- queueMessage(text: string): void {
+ queueMessage(text: string, images?: readonly ImageInput[]): void {
const trimmed = text.trim();
- if (trimmed.length === 0) return;
- const msg: ChatQueueMessage = {
+ if (trimmed.length === 0 && !images?.length) return;
+ const msg: ChatQueueMessage & { requestId: string } = {
type: "chat.queue",
+ requestId: deps.createRequestId(),
conversationId: deps.conversationId,
text: trimmed,
+ ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
};
+ try {
+ savePendingInputs([...pendingInputs, { request: msg }]);
+ } catch (error) {
+ deps.onError?.("Unable to save input; the message has not been queued", error);
+ throw error;
+ }
+ deps.transport.send(msg);
+ },
+
+ cancelQueuedMessage(messageId: string): void {
+ // Fire-and-forget + idempotent (per the contract). The caller optimistically
+ // hides the row; the message-queue surface update reconciles. A cancel of
+ // an already-drained / unknown message is a silent server no-op, so there
+ // is no local-state change to make and nothing to roll back on a stray
+ // `chat.error` (which only fires for a malformed send — a client that
+ // sends the id it just rendered never hits it).
+ const msg: ChatQueueCancelMessage = {
+ type: "chat.queue.cancel",
+ conversationId: deps.conversationId,
+ messageId,
+ };
deps.transport.send(msg);
},
@@ -352,6 +820,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
_model = model;
},
+ setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void {
+ _namedAgent = snapshot;
+ },
+
async setChatLimit(limit: number): Promise<void> {
const prev = chatLimit;
chatLimit = normalizeChatLimit(limit);
@@ -369,8 +841,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
// server instead (CR-5 `?limit=`), so a huge conversation never ships
// whole. The post-sync window re-asserts the cap either way.
const windowSize = initialWindowSize(chatLimit);
+ const currentRevision = revision;
+ const cachedEpoch = await deps.cache.historyEpoch(deps.conversationId);
const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
+ if (!disposed && revision === currentRevision && cachedEpoch !== undefined) {
+ historyEpoch = cachedEpoch;
transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
}
await syncTail(windowSize);
@@ -381,7 +856,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
async showEarlier(): Promise<void> {
if (disposed) return;
const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
+ if (!hasEarlier()) return;
const want = unloadCount(chatLimit);
try {
const earlier = await backfillOlder(oldest, want);
@@ -392,18 +867,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
}
},
- resync(): void {
+ invalidateHistory(epoch): void {
+ if (!disposed && (historyEpoch === undefined || epoch > historyEpoch)) resetTranscript(epoch);
+ },
+
+ resync(epoch?: number): void {
if (disposed) return;
+ if (epoch !== undefined && (historyEpoch === undefined || epoch > historyEpoch))
+ resetTranscript(epoch);
// A turn may have sealed while we were disconnected (missed `turn-sealed`):
// clear the now-stale spinner BEFORE re-subscribing, so a finished turn
// doesn't spin forever. A still-running turn's replay re-asserts it.
- transcript = clearGenerating(transcript);
+ flushEvents();
+ recoveryPaused = false;
+ if (epoch === undefined) {
+ recovering = false;
+ recoveringTurnId = undefined;
+ cancelRecoveryTimer();
+ transcript = clearGenerating(transcript);
+ }
void syncTail();
void syncMetrics();
},
dispose(): void {
disposed = true;
+ cancelRecoveryTimer();
+ finishedRecoveryTurns.clear();
+ pendingEvents = [];
},
};
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index 8f36994..5040e67 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,5 +1,8 @@
import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire";
+import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
import { describe, expect, it, vi } from "vitest";
+import { createIdbChunkStore } from "../../adapters/idb";
+import { createConversationCache } from "../conversation-cache";
import { createChatStore } from "./store.svelte";
import {
createFakeCache,
@@ -29,6 +32,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -61,6 +65,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -104,6 +109,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -128,6 +134,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
@@ -150,6 +157,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -182,6 +190,7 @@ describe("createChatStore", () => {
it("send omits images on the wire when none are staged (backward compatible)", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -198,6 +207,7 @@ describe("createChatStore", () => {
it("send omits images on the wire for an empty array", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -214,6 +224,7 @@ describe("createChatStore", () => {
it("send allows an images-only message (empty text)", () => {
const transport = createFakeTransport();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: createFakeHistorySync().impl,
@@ -238,6 +249,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -252,16 +264,52 @@ describe("createChatStore", () => {
expect(transport.sentQueue[0]?.type).toBe("chat.queue");
expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
expect(transport.sentQueue[0]?.text).toBe("steer left");
+ expect(transport.sentQueue[0]).not.toHaveProperty("images");
store.dispose();
});
+ it.each([" describe this ", "", " "])("queues images with text %j", (text) => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ workspaceId: "images-workspace",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+ const images: readonly ImageInput[] = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/photo.jpg" },
+ ];
+
+ store.queueMessage(text, images);
+
+ expect(transport.sentQueue).toEqual([
+ {
+ type: "chat.queue",
+ requestId: expect.any(String),
+ conversationId: CONV_ID,
+ workspaceId: "images-workspace",
+ text: text.trim(),
+ images,
+ },
+ ]);
+ expect(transport.sentQueue[0]?.images).not.toBe(images);
+ expect(transport.sent).toHaveLength(0);
+ expect(store.chunks).toHaveLength(0);
+ store.dispose();
+ });
+
it("trims whitespace before sending", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -282,6 +330,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -291,6 +340,7 @@ describe("createChatStore", () => {
store.queueMessage(" ");
store.queueMessage("");
+ store.queueMessage("", []);
expect(transport.sentQueue).toHaveLength(0);
@@ -303,6 +353,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -318,12 +369,88 @@ describe("createChatStore", () => {
});
});
+ describe("cancelQueuedMessage (chat.queue.cancel)", () => {
+ it("posts a chat.queue.cancel with conversationId + messageId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.cancelQueuedMessage("msg-42");
+
+ expect(transport.sent).toHaveLength(0); // chat.send stays empty
+ expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty
+ expect(transport.sentCancels).toHaveLength(1);
+ expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel");
+ expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sentCancels[0]?.messageId).toBe("msg-42");
+
+ store.dispose();
+ });
+
+ it("does NOT touch the transcript (a cancelled message never runs)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.cancelQueuedMessage("msg-42");
+
+ expect(store.chunks).toHaveLength(0); // no transcript echo / change
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // The server no-ops an already-drained / unknown id; the FE fires-and-
+ // forgetgets, so even a repeat cancel is forwarded.
+ store.cancelQueuedMessage("msg-42");
+ store.cancelQueuedMessage("msg-42");
+
+ expect(transport.sentCancels).toHaveLength(2);
+ expect(transport.sentCancels[1]?.messageId).toBe("msg-42");
+
+ store.dispose();
+ });
+ });
+
it("chat.error sets error", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -353,6 +480,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(3, "assistant")];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -381,6 +509,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(1, "assistant")];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -402,6 +531,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -434,6 +564,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -456,7 +587,7 @@ describe("createChatStore", () => {
store.dispose();
});
- it("overlapping syncs are guarded", async () => {
+ it("overlapping syncs coalesce into a sequential rerun", async () => {
const transport = createFakeTransport();
const _historySync = createFakeHistorySync();
const metricsSync = createFakeMetricsSync();
@@ -474,10 +605,11 @@ describe("createChatStore", () => {
if (callCount === 1) {
await firstSyncPromise;
}
- return { chunks: [], latestSeq: sinceSeq };
+ return { chunks: [], latestSeq: sinceSeq, historyEpoch: 0 };
};
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: slowHistorySync,
@@ -501,7 +633,7 @@ describe("createChatStore", () => {
// Release the first sync
resolveFirstSync?.();
- await new Promise((r) => setTimeout(r, 10));
+ await vi.waitFor(() => expect(callCount).toBe(2));
store.dispose();
});
@@ -512,6 +644,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -557,6 +690,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
model: "openai/gpt-4",
transport: transport.impl,
@@ -581,6 +715,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -604,6 +739,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -634,6 +770,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -654,6 +791,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -678,6 +816,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -716,6 +855,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -777,6 +917,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -821,6 +962,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -893,6 +1035,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -959,6 +1102,7 @@ describe("createChatStore", () => {
];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -983,6 +1127,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1014,6 +1159,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1038,6 +1184,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1072,6 +1219,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1118,6 +1266,7 @@ describe("createChatStore", () => {
const cache = createFakeCache();
let atBottom = false; // reader scrolled up
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1172,6 +1321,7 @@ describe("createChatStore", () => {
const cache = createFakeCache();
let atBottom = false;
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1210,6 +1360,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1239,6 +1390,7 @@ describe("createChatStore", () => {
historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1274,6 +1426,7 @@ describe("createChatStore", () => {
historySync.returnChunks = [makeStoredChunk(3)];
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1298,6 +1451,7 @@ describe("createChatStore", () => {
historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1335,6 +1489,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1367,6 +1522,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1398,6 +1554,7 @@ describe("createChatStore", () => {
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1430,6 +1587,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1467,6 +1625,7 @@ describe("createChatStore", () => {
Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1498,6 +1657,7 @@ describe("createChatStore", () => {
// Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1525,6 +1685,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1564,6 +1725,7 @@ describe("createChatStore", () => {
Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
);
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1573,6 +1735,7 @@ describe("createChatStore", () => {
});
await store.load(); // only 50 chunks → all loaded, window starts at seq 1
expect(store.chunks).toHaveLength(50);
+ await store.showEarlier(); // Probe server exhaustion; seq 1 alone is not a boundary.
expect(store.hasEarlier).toBe(false);
const callsAfterLoad = historySync.calls.length;
@@ -1590,6 +1753,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1619,6 +1783,7 @@ describe("createChatStore", () => {
const metricsSync = createFakeMetricsSync();
const cache = createFakeCache();
const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
conversationId: CONV_ID,
transport: transport.impl,
historySync: historySync.impl,
@@ -1634,3 +1799,467 @@ describe("createChatStore", () => {
expect(metricsSync.calls).toHaveLength(0);
});
});
+
+describe("createChatStore — batched delta application (scheduleFlush)", () => {
+ function setup() {
+ const scheduled: (() => void)[] = [];
+ const historySync = createFakeHistorySync();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: historySync.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ scheduleFlush: (flush) => scheduled.push(flush),
+ });
+ const runScheduled = (): void => {
+ for (const f of scheduled.splice(0)) f();
+ };
+ return { store, scheduled, runScheduled, historySync };
+ }
+ const text = (delta: string) =>
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta });
+
+ it("queues a burst and applies it in ONE scheduled flush", () => {
+ const { store, scheduled, runScheduled } = setup();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ for (let i = 0; i < 500; i++) store.handleDelta(text("x"));
+
+ expect(scheduled).toHaveLength(1);
+ expect(store.chunks).toHaveLength(0);
+
+ runScheduled();
+ expect(store.chunks).toHaveLength(1);
+ expect((store.chunks[0]?.chunk as { text: string }).text).toBe("x".repeat(500));
+ expect(store.generating).toBe(true);
+ store.dispose();
+ });
+
+ it("schedules a new flush for events arriving after a flush", () => {
+ const { store, scheduled, runScheduled } = setup();
+ store.handleDelta(text("a"));
+ runScheduled();
+ store.handleDelta(text("b"));
+ expect(scheduled).toHaveLength(1);
+ runScheduled();
+ expect((store.chunks[0]?.chunk as { text: string }).text).toBe("ab");
+ store.dispose();
+ });
+
+ it("a sealed turn in the batch still triggers a history sync", async () => {
+ const { store, runScheduled, historySync } = setup();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(text("done"));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ expect(historySync.calls).toHaveLength(0);
+ runScheduled();
+ await vi.waitFor(() => expect(historySync.calls.length).toBeGreaterThan(0));
+ store.dispose();
+ });
+
+ it("send() flushes queued events first so the user echo lands after them", () => {
+ const { store } = setup();
+ store.handleDelta(text("earlier reply"));
+ store.send("next question");
+ const roles = store.chunks.map((c) => c.role);
+ expect(roles).toEqual(["assistant", "user"]);
+ store.dispose();
+ });
+
+ it("chunks getter returns a stable array for an unchanged transcript", () => {
+ const { store, runScheduled } = setup();
+ store.handleDelta(text("a"));
+ runScheduled();
+ expect(store.chunks).toBe(store.chunks);
+ store.dispose();
+ });
+
+ it("a flush after dispose is a no-op", () => {
+ const { store, scheduled } = setup();
+ store.handleDelta(text("a"));
+ store.dispose();
+ for (const f of scheduled) f();
+ expect(store.chunks).toHaveLength(0);
+ });
+});
+
+describe("createChatStore — cacheTimer", () => {
+ function makeStore(now: () => number) {
+ return createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ now,
+ });
+ }
+ const call = (stepId: string, toolCallId: string) =>
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "read_file",
+ input: {},
+ });
+ const result = (stepId: string, toolCallId: string) =>
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ toolCallId,
+ toolName: "read_file",
+ content: "ok",
+ isError: false,
+ });
+ const stepComplete = (stepId: string) =>
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: stepId as StepId,
+ });
+ const done = (turnId: string) =>
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId, reason: "end-turn" });
+
+ it("is idle until something happens", () => {
+ const store = makeStore(() => 1000);
+ expect(store.cacheTimer.running).toBeNull();
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.cacheTimer.running).toBeNull();
+ store.dispose();
+ });
+
+ it("times a step's tool execution with the injected clock and stamps it", () => {
+ let clock = 0;
+ const store = makeStore(() => clock);
+ store.handleDelta(call("t1#0", "a"));
+ clock = 1_000;
+ store.handleDelta(stepComplete("t1#0"));
+ expect(store.cacheTimer.running).toMatchObject({ kind: "step", startedAt: 1_000 });
+ clock = 31_000;
+ store.handleDelta(result("t1#0", "a"));
+ expect(store.cacheTimer.running).toBeNull();
+ expect(store.cacheTimer.stepStamps.get("t1#0")).toBe(30_000);
+ store.dispose();
+ });
+
+ it("starts a turn gap on done and stamps it on send", () => {
+ let clock = 5_000;
+ const store = makeStore(() => clock);
+ store.handleDelta(done("t1"));
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 });
+ clock = 125_000;
+ store.send("next");
+ expect(store.cacheTimer.running).toBeNull();
+ expect(store.cacheTimer.turnStamps.get("t1")).toBe(120_000);
+ store.dispose();
+ });
+
+ it("ignores another conversation's events", () => {
+ const store = makeStore(() => 5_000);
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: "other", turnId: "t1", reason: "end-turn" }),
+ );
+ expect(store.cacheTimer.running).toBeNull();
+ store.dispose();
+ });
+
+ it("noteLastActivity seeds a turn gap, keeps the later start, and is ignored while generating", () => {
+ const store = makeStore(() => 5_000);
+ store.noteLastActivity(3_000);
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: null, startedAt: 3_000 });
+ store.handleDelta(done("t1"));
+ expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 });
+ store.noteLastActivity(4_000); // an older seed never rewinds it
+ expect(store.cacheTimer.running).toMatchObject({ startedAt: 5_000 });
+ store.dispose();
+
+ const busy = makeStore(() => 5_000);
+ busy.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ busy.noteLastActivity(4_000);
+ expect(busy.cacheTimer.running).toBeNull();
+ busy.dispose();
+ });
+});
+
+describe("history epochs", () => {
+ it.each([
+ "tail",
+ "backfill",
+ ])("recovers missed compaction on %s and exhausts non-1 history", async (path) => {
+ const history = createFakeHistorySync();
+ const cache = createFakeCache();
+ history.returnChunks = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1));
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ chatLimit: 10,
+ });
+ await store.load();
+ history.historyEpoch = 1;
+ history.returnChunks = [makeStoredChunk(101), makeStoredChunk(102)];
+ if (path === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(store.chunks[0]?.seq).toBe(101));
+ } else await store.showEarlier();
+ expect(store.chunks.map((c) => c.seq)).toEqual([101, 102]);
+ expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101, 102]);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(store.hasEarlier).toBe(false);
+ const calls = history.calls.length;
+ await store.showEarlier();
+ expect(history.calls).toHaveLength(calls);
+ store.dispose();
+ });
+});
+
+it("a compaction broadcast during an in-flight fetch discards the old response", async () => {
+ const history = createFakeHistorySync();
+ history.returnChunks = [makeStoredChunk(101)];
+ let resolve!: (res: import("@dispatch/transport-contract").ConversationHistoryResponse) => void;
+ const first = new Promise<import("@dispatch/transport-contract").ConversationHistoryResponse>(
+ (r) => {
+ resolve = r;
+ },
+ );
+ const sync = vi
+ .fn<import("./ports").HistorySync>()
+ .mockImplementationOnce(() => first)
+ .mockImplementation(history.impl);
+ const cache = createFakeCache();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: cache.impl,
+ });
+ const loading = store.load();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1));
+ history.historyEpoch = 1;
+ store.resync(1);
+ resolve({ chunks: [makeStoredChunk(1)], latestSeq: 1, historyEpoch: 0 });
+ await loading;
+ expect(sync).toHaveBeenCalledTimes(2);
+ expect(store.chunks.map((c) => c.seq)).toEqual([101]);
+ expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101]);
+ store.dispose();
+});
+
+it("reloads unknown legacy cache epochs rather than merging pre-upgrade rows", async () => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const storage = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const cache = createConversationCache(storage);
+ await storage.append(CONV_ID, [makeStoredChunk(1)]);
+ const history = createFakeHistorySync();
+ history.returnChunks = [makeStoredChunk(101)];
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ });
+ try {
+ await store.load();
+ expect(history.calls[0]).toMatchObject({ sinceSeq: 0 });
+ expect(store.chunks.map((c) => c.seq)).toEqual([101]);
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect((await cache.load(CONV_ID)).map((c) => c.seq)).toEqual([101]);
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+});
+
+describe("server epoch rollback", () => {
+ it.each([
+ "tail",
+ "backfill",
+ ])("converges on %s with a real cache and a fresh bounded fetch", async (path) => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const storage = createIdbChunkStore({ indexedDB: new IDBFactory() });
+ const cache = createConversationCache(storage);
+ const old = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1));
+ await cache.commit(CONV_ID, path === "backfill" ? old.slice(-7) : old, 5);
+ const history = createFakeHistorySync();
+ history.historyEpoch = 5;
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ chatLimit: 10,
+ });
+ try {
+ await store.load();
+ history.historyEpoch = 0;
+ // Recreated ids can reuse seqs with completely different content.
+ const restored: StoredChunk = {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "restored" },
+ };
+ history.returnChunks = [restored];
+ const before = history.calls.length;
+ if (path === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ } else await store.showEarlier();
+ expect(history.calls.length - before).toBeLessThanOrEqual(2);
+ expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } });
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([restored]);
+ expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]);
+ expect(store.hasEarlier).toBe(false);
+ expect(store.error).toBeNull();
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it("bounds reruns even when every response is invalidated by another compaction", async () => {
+ let calls = 0;
+ let churn = true;
+ let store!: ReturnType<typeof createChatStore>;
+ const sync: import("./ports").HistorySync = async () => {
+ calls++;
+ // Stop the old broken loop safely rather than hanging the test worker.
+ if (churn && calls <= 6) store.resync(calls);
+ return { chunks: [], latestSeq: 0, historyEpoch: calls - 1 };
+ };
+ store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+ try {
+ await store.load();
+ expect(calls).toBe(3);
+ expect(store.pendingSync).toBe(false);
+ expect(store.error).toMatch(/history changed repeatedly/i);
+ churn = false;
+ store.resync();
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(calls).toBe(4);
+ expect(store.error).toBeNull();
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ "tail",
+ "backfill",
+])("rejects a delayed old %s after a lower-epoch history is observed", async (delayed) => {
+ vi.stubGlobal("IDBKeyRange", IDBKeyRange);
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.commit(
+ CONV_ID,
+ Array.from({ length: 7 }, (_, i) => makeStoredChunk(14 + i)),
+ 5,
+ );
+ const restored: StoredChunk = {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "new history" },
+ };
+ type Response = import("@dispatch/transport-contract").ConversationHistoryResponse;
+ let resolve!: (res: Response) => void;
+ const slow = new Promise<Response>((r) => {
+ resolve = r;
+ });
+ let delayNext = false;
+ let epoch = 5;
+ const sync = vi.fn<import("./ports").HistorySync>().mockImplementation(async () => {
+ if (delayNext) {
+ delayNext = false;
+ return slow;
+ }
+ return {
+ chunks: epoch === 5 ? [] : [restored],
+ latestSeq: epoch === 5 ? 20 : 1,
+ historyEpoch: epoch,
+ };
+ });
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: sync,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ chatLimit: 10,
+ });
+ try {
+ await store.load();
+ delayNext = true;
+ let earlier: Promise<void>;
+ if (delayed === "tail") {
+ store.resync();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
+ epoch = 0;
+ earlier = store.showEarlier();
+ await earlier; // Invalidates the old tail and requests a sequential rerun.
+ } else {
+ earlier = store.showEarlier();
+ await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
+ epoch = 0;
+ store.resync();
+ await vi.waitFor(() => expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]));
+ }
+ resolve({ chunks: [makeStoredChunk(21)], latestSeq: 21, historyEpoch: 5 });
+ await earlier;
+ await vi.waitFor(() => expect(store.pendingSync).toBe(false));
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([restored]);
+ expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]);
+ expect(sync).toHaveBeenCalledTimes(4);
+ } finally {
+ store.dispose();
+ vi.unstubAllGlobals();
+ }
+});
+
+it("replaces an empty high-epoch cache with the server's empty lower epoch in one request", async () => {
+ const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() }));
+ await cache.reset(CONV_ID, 8);
+ const history = createFakeHistorySync();
+ const store = createChatStore({
+ createRequestId: () => crypto.randomUUID(),
+ conversationId: CONV_ID,
+ transport: createFakeTransport().impl,
+ historySync: history.impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache,
+ });
+ try {
+ await store.load();
+ expect(history.calls).toHaveLength(1);
+ expect(await cache.historyEpoch(CONV_ID)).toBe(0);
+ expect(await cache.load(CONV_ID)).toEqual([]);
+ expect(store.chunks).toEqual([]);
+ expect(store.hasEarlier).toBe(false);
+ } finally {
+ store.dispose();
+ }
+});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index 26c5590..41cfb3b 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -1,4 +1,8 @@
-import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+import type {
+ ChatQueueCancelMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
+} from "@dispatch/transport-contract";
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
@@ -8,19 +12,25 @@ export interface FakeTransport {
readonly sent: ChatSendMessage[];
/** All `chat.queue` messages sent through the fake transport. */
readonly sentQueue: ChatQueueMessage[];
+ /** All `chat.queue.cancel` messages sent through the fake transport. */
+ readonly sentCancels: ChatQueueCancelMessage[];
readonly impl: ChatTransport;
}
export function createFakeTransport(): FakeTransport {
const sent: ChatSendMessage[] = [];
const sentQueue: ChatQueueMessage[] = [];
+ const sentCancels: ChatQueueCancelMessage[] = [];
return {
sent,
sentQueue,
+ sentCancels,
impl: {
send(msg) {
if (msg.type === "chat.queue") {
sentQueue.push(msg);
+ } else if (msg.type === "chat.queue.cancel") {
+ sentCancels.push(msg);
} else {
sent.push(msg);
}
@@ -33,14 +43,22 @@ export interface FakeHistorySync {
readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
/** Set the chunks to return on the next call. */
returnChunks: readonly StoredChunk[];
+ historyEpoch: number;
readonly impl: HistorySync;
}
export function createFakeHistorySync(): FakeHistorySync {
const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
let returnChunks: readonly StoredChunk[] = [];
+ let historyEpoch = 0;
return {
calls,
+ get historyEpoch() {
+ return historyEpoch;
+ },
+ set historyEpoch(v: number) {
+ historyEpoch = v;
+ },
get returnChunks() {
return returnChunks;
},
@@ -62,7 +80,7 @@ export function createFakeHistorySync(): FakeHistorySync {
chunks = chunks.slice(-window.limit);
}
const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
- return { chunks, latestSeq };
+ return { chunks, latestSeq, historyEpoch };
},
};
}
@@ -112,13 +130,28 @@ export interface FakeCache {
export function createFakeCache(): FakeCache {
const store = new Map<string, StoredChunk[]>();
+ const epochs = new Map<string, number>();
return {
store,
impl: {
+ async historyEpoch(id) {
+ return epochs.get(id) ?? 0;
+ },
+ async reset(id, epoch) {
+ store.delete(id);
+ epochs.set(id, epoch);
+ },
+ async replace(id, incoming, epoch, expectedEpoch) {
+ if ((epochs.get(id) ?? 0) !== expectedEpoch) return false;
+ store.set(id, [...incoming]);
+ epochs.set(id, epoch);
+ return true;
+ },
async load(conversationId) {
return store.get(conversationId) ?? [];
},
- async commit(conversationId, incoming) {
+ async commit(conversationId, incoming, epoch) {
+ if (epoch !== undefined) epochs.set(conversationId, epoch);
const existing = store.get(conversationId) ?? [];
const seen = new Set(existing.map((c) => c.seq));
const toAppend = incoming.filter((c) => !seen.has(c.seq));
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 5f8067d..f98fdbb 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,12 +3,26 @@ import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
-import type { TurnMetricsEntry } from "../../core/metrics";
+import {
+ type CacheTimerState,
+ initialCacheTimerState,
+ type TurnMetricsEntry,
+} from "../../core/metrics";
+import type { ConvertedJpeg } from "./adapter/image-encode";
+import CacheTimerBadge from "./ui/CacheTimerBadge.svelte";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte";
+/** Collapsed card bodies render lazily — open every card so its body mounts. */
+async function openAllCollapses(container: HTMLElement): Promise<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]');
+ for (const box of boxes) {
+ if (!box.checked) await userEvent.click(box);
+ }
+}
+
describe("ChatView", () => {
it("renders a message's text chunk", () => {
const chunks: RenderedChunk[] = [
@@ -42,6 +56,25 @@ describe("ChatView", () => {
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
+ it("renders local-path Markdown images through the backend image route", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "![pic](/home/u/pic.png)" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ });
+
it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
const chunks: RenderedChunk[] = [
{ seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
@@ -81,7 +114,7 @@ describe("ChatView", () => {
expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
});
- it("renders tool-call chunks", () => {
+ it("renders tool-call chunks", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -96,16 +129,17 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
const pre = screen.getByText((content, element) => {
return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
});
expect(pre).toBeInTheDocument();
});
- it("renders tool-result chunks", () => {
+ it("renders tool-result chunks", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -121,9 +155,10 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
expect(screen.getByText("file contents here")).toBeInTheDocument();
});
@@ -199,7 +234,7 @@ describe("ChatView", () => {
expect(log.children).toHaveLength(0);
});
- it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ it("renders parallel tool calls (shared stepId) as separate bubbles", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -241,15 +276,18 @@ describe("ChatView", () => {
];
const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- // Batched calls render as collapsible cards (one per call), not a list.
+ // Parallel calls render as independent collapsible cards (one per bubble).
const collapses = container.querySelectorAll(".collapse");
expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
+
// Both call names + the available result are shown; the result is absorbed
// (no standalone tool-result card).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
expect(screen.getByText("contents-of-a")).toBeInTheDocument();
});
@@ -271,8 +309,7 @@ describe("ChatView", () => {
expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
expect(collapse).not.toHaveClass("collapse-plus");
// Visible bubble, like tool cards.
- expect(collapse).toHaveClass("bg-base-200");
- expect(collapse).toHaveClass("rounded-box");
+ expect(collapse).toHaveClass("inset-well"); // shared recessed panel (app.css)
expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
});
@@ -372,11 +409,39 @@ describe("ChatView", () => {
expect(screen.getByText("Hi")).toBeInTheDocument();
expect(screen.getByText("Hello!")).toBeInTheDocument();
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
- expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument();
expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
});
+ it("renders a per-step cache hit badge coloured by level", () => {
+ const step = {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 750 },
+ decodeMs: 100,
+ };
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "t",
+ input: {},
+ stepId: step.stepId,
+ },
+ provisional: false,
+ },
+ ];
+ const turnMetrics: TurnMetricsEntry[] = [{ turnId: "t1", steps: [step], total: null }];
+ render(ChatView, { props: { chunks, turnMetrics } });
+ const badge = screen.getByTestId("step-cache-badge");
+ expect(badge).toHaveTextContent("75%");
+ expect(badge).toHaveClass("badge-success");
+ });
+
it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
const chunks: RenderedChunk[] = [
{ seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
@@ -477,11 +542,11 @@ describe("ChatView", () => {
// Both step-metrics and turn-metrics render
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument();
// They are in separate elements (different rows)
- const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
- const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
+ const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div");
expect(stepEl).not.toBe(turnEl);
});
@@ -553,9 +618,9 @@ describe("ChatView", () => {
// Step metrics rendered
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument();
// Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument();
// No "null" or "undefined" in the DOM
expect(screen.queryByText("null")).toBeNull();
expect(screen.queryByText("undefined")).toBeNull();
@@ -601,8 +666,7 @@ describe("ChatView", () => {
render(ChatView, { props: { chunks, turnMetrics } });
// Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
// Turn total should NOT render (total is null — turn still in progress)
expect(screen.queryByText(/^turn/)).toBeNull();
@@ -707,7 +771,7 @@ describe("ChatView", () => {
expect(container.querySelector("img")?.getAttribute("src")).toBe(url);
});
- it("renders a consult_vision tool call/result like any other tool", () => {
+ it("renders a consult_vision tool call/result like any other tool", async () => {
// read_image is GONE — replaced by consult_vision (opens a vision-model
// conversation, attaches the image + question, returns the answer). It is
// a normal tool call: rendered generically by toolName.
@@ -737,9 +801,10 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0);
expect(screen.getByText("a red square on a white background")).toBeInTheDocument();
});
@@ -784,6 +849,71 @@ describe("ChatView", () => {
});
describe("Composer", () => {
+ it.each([
+ { status: "idle", queuedMessageCount: 0, text: "", label: "Send" },
+ { status: "running", queuedMessageCount: 0, text: "next", label: "Queue" },
+ { status: "running", queuedMessageCount: 0, text: "", label: "Stop generation" },
+ { status: "running", queuedMessageCount: 1, text: "", label: "Flush" },
+ ] as const)("keeps $label at the same fixed width", async ({ label, text, ...props }) => {
+ const user = userEvent.setup();
+ render(Composer, { props: { ...props, onSend: vi.fn(), onQueue: vi.fn(), onStop: vi.fn() } });
+ if (text) await user.type(screen.getByRole("textbox", { name: "Message input" }), text);
+ const button = screen.getByRole("button", { name: label });
+ expect(button).toHaveClass("w-20", "shrink-0");
+ expect(button).not.toHaveClass("w-36");
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => {
+ const onStop = vi.fn();
+ const onSend = vi.fn();
+ const onQueue = vi.fn();
+ const user = userEvent.setup();
+ const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } });
+ const expectPlainStop = () => {
+ const button = screen.getByRole("button", { name: "Stop generation" });
+ expect(button).toHaveTextContent(/^Stop$/);
+ expect(button).not.toHaveAttribute("title");
+ };
+ expectPlainStop(); // No queue snapshot available.
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop();
+ await user.click(screen.getByRole("button", { name: "Stop generation" }));
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ for (const queuedMessageCount of [1, 2]) {
+ await rerender({ queuedMessageCount });
+ const button = screen.getByRole("button", { name: "Flush" });
+ expect(button).toHaveTextContent(/^Flush$/);
+ expect(button).toHaveAttribute(
+ "title",
+ "Stop the current response and start the queued message now",
+ );
+ }
+ await user.click(screen.getByRole("button", { name: "Flush" }));
+ expect(onStop).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onQueue).not.toHaveBeenCalled();
+
+ const input = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(input, "next");
+ expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Flush" })).toBeNull();
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined);
+ expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument();
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop(); // Queue drained or cancelled.
+ await rerender({ queuedMessageCount: 1, status: "idle" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status: "error" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status, queuedMessageCount: undefined });
+ expectPlainStop();
+ });
+
it("calls onSend with the typed text and clears", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
@@ -964,17 +1094,28 @@ describe("Composer", () => {
expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
});
- it("queues (steers) text-only and never forwards images", async () => {
- // While running, the Send button becomes "Queue"; steering is text-only.
+ it("still queues text-only with images omitted", async () => {
const onQueue = vi.fn();
const onSend = vi.fn();
const user = userEvent.setup();
- const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+ render(Composer, { props: { onSend, onQueue, status: "running" } });
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("queues text and images while running and clears the composer", async () => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, {
+ props: { onSend, onQueue, status: "running", encodeJpeg: async () => null },
+ });
const textarea = screen.getByRole("textbox", { name: "Message input" });
await user.type(textarea, "steer here");
- // Also stage an image — it must NOT be forwarded on a queue.
+ // Stage an image along with the steering text.
const file = new File(["PNG"], "shot.png", { type: "image/png" });
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -984,8 +1125,187 @@ describe("Composer", () => {
});
await user.click(screen.getByRole("button", { name: "Queue" }));
- expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [
+ { url: "data:image/png;base64,UE5H", mimeType: "image/png" },
+ ]);
expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("keeps images and text staged while %s without onQueue", async (status) => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container, rerender } = render(Composer, { props: { onSend, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "{Enter}"); // image-only: no queue callback
+ await user.type(textarea, "keep this{Enter}");
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+
+ expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("keep this");
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled();
+ // Once idle, the same staged input can actually be sent.
+ await rerender({ status: "idle" });
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+});
+
+describe("Composer image compression", () => {
+ /** Stage a PNG through the file picker and wait for the thumbnail. */
+ async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> {
+ const file = new File([content], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+ }
+
+ /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */
+ function fakeJpeg(): ConvertedJpeg {
+ return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" };
+ }
+
+ it("shows the compress toggle CHECKED by default for a staged PNG", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ await stagePng(container);
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ expect(toggle).toBeChecked();
+ });
+
+ it("hides the compress toggle for already-lossy formats (JPEG)", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+
+ expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument();
+ });
+
+ it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+
+ // The async conversion lands and the staged URL reflects what will be sent.
+ await vi.waitFor(() => {
+ expect(encodeJpeg).toHaveBeenCalledTimes(1);
+ });
+ await vi.waitFor(() => {
+ const thumb = container.querySelector("img[alt='image/jpeg']");
+ expect(thumb).not.toBeNull();
+ expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images).toHaveLength(1);
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("checkbox", { name: "Compress image" }));
+ expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked();
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("re-checking uses the CACHED conversion — no second encode", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ await user.click(toggle); // OFF → original
+ await user.click(toggle); // ON again → must reuse the cached JPEG
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("falls back to the original image when conversion fails", async () => {
+ const encodeJpeg = vi.fn(async () => {
+ throw new Error("decode exploded");
+ });
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
});
});
@@ -1006,7 +1326,9 @@ describe("ModelSelector", () => {
expect(keySelect).toHaveValue("anthropic");
expect(modelSelect).toHaveValue("claude-3");
- expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with
+ // zero configured agents.
+ expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]);
// only the models under the selected key
expect(optionValues(modelSelect)).toEqual(["claude-3"]);
});
@@ -1041,6 +1363,133 @@ describe("ModelSelector", () => {
expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
});
+ it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => {
+ const onSelectNamedAgent = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4"];
+ const namedAgents = [
+ { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" },
+ ];
+
+ const { rerender } = render(ModelSelector, {
+ props: {
+ models,
+ selected: "openai/gpt-4",
+ onSelect: vi.fn(),
+ namedAgents,
+ onSelectNamedAgent,
+ },
+ });
+
+ // Switching the provider to "Named Agents" jumps to the first agent (mirrors
+ // a raw provider jumping to its first model).
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Key selector" }),
+ "__named_agents__",
+ );
+ expect(onSelectNamedAgent).toHaveBeenCalledWith({
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ // The parent (store) applies the selection → re-render with the resolved raw
+ // model + snapshot, exactly as `selectModel(model, snapshot)` would do.
+ const codingAgent = namedAgents[0];
+ await rerender({
+ models,
+ selected: codingAgent?.model ?? "",
+ onSelect: vi.fn(),
+ namedAgents,
+ namedAgentSnapshot: {
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ },
+ onSelectNamedAgent,
+ });
+
+ // The model dropdown now lists the named agents; pick a different one.
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Model selector" }),
+ "Research Agent",
+ );
+ expect(onSelectNamedAgent).toHaveBeenLastCalledWith({
+ namedAgentId: "a2",
+ namedAgentName: "Research Agent",
+ model: "umans/umans-glm-5.2",
+ });
+ });
+
+ it("shows a disabled placeholder when the synthetic provider has no configured agents", () => {
+ render(ModelSelector, {
+ props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() },
+ });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ const options = within(modelSelect).getAllByRole("option");
+ // With the raw openai provider selected the model dropdown lists gpt-4; the
+ // "No named agents configured" placeholder appears only under the synthetic
+ // provider (which we'd select to see it). Assert the disabled/empty handling
+ // by checking the synthetic provider is present in the key selector.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ expect(optionValues(keySelect)).toContain("__named_agents__");
+ // Switch to the (empty) synthetic provider → disabled "No named agents configured".
+ expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow();
+ expect(options).toHaveLength(1);
+ });
+
+ it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => {
+ // A conversation selected via a Named Agent retains its snapshot even after
+ // the agent is gone from the current list (plan §7).
+ const namedAgentSnapshot = {
+ namedAgentId: "gone",
+ namedAgentName: "Old Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ render(ModelSelector, {
+ props: {
+ models: [],
+ selected: "opencode-1/claude-sonnet-4.5",
+ onSelect: vi.fn(),
+ namedAgents: [],
+ namedAgentSnapshot,
+ },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ expect(keySelect).toHaveValue("__named_agents__");
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(modelSelect).toHaveValue("Old Agent");
+ expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument();
+ });
+
+ it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => {
+ const onSelect = vi.fn();
+ const onSelectNamedAgent = vi.fn();
+ const user = userEvent.setup();
+ const namedAgentSnapshot = {
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ render(ModelSelector, {
+ props: {
+ models: ["openai/gpt-4"],
+ selected: "opencode-1/claude-sonnet-4.5",
+ onSelect,
+ namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }],
+ namedAgentSnapshot,
+ onSelectNamedAgent,
+ },
+ });
+
+ // Switch from Named Agents to a raw provider → raw model selection clears the snapshot.
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai");
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4");
+ expect(onSelectNamedAgent).not.toHaveBeenCalled();
+ });
+
it("marks vision-capable models in the model dropdown", () => {
const models = ["kimi/k2", "kimi/k1.5"];
const modelInfo = {
@@ -1089,33 +1538,95 @@ describe("ModelSelector", () => {
});
expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
});
+
+ it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => {
+ const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-2/m2", onSelect },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const options = within(keySelect).getAllByRole("option");
+ // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider),
+ // and values stay RAW (opencode-N).
+ expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([
+ "__named_agents__",
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ ]);
+ expect(options[1]?.textContent).toContain("OpenCode #1");
+ expect(options[2]?.textContent).toContain("OpenCode #2");
+ expect(options[3]?.textContent).toContain("OpenCode #10");
+ });
+
+ it("shows a disappeared saved credential as unavailable without silently falling back", () => {
+ // Saved selection references opencode-5, but the catalog no longer has it.
+ const models = ["opencode-1/deepseek-v4-flash"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect },
+ });
+
+ // The unavailable key is surfaced as an explicit (disabled) option + notice.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i);
+ expect(unavailable).toBeInTheDocument();
+ expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument();
+ // No silent switch is attempted.
+ expect(onSelect).not.toHaveBeenCalled();
+ });
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+ it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save: vi.fn() },
+ });
const select = screen.getByRole("combobox", { name: "Reasoning effort" });
expect(select).toHaveValue("high");
expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
- // All five ladder levels are offered.
- expect(within(select).getAllByRole("option")).toHaveLength(5);
+ // "Off" first, then the five ladder levels.
+ const options = within(select).getAllByRole("option");
+ expect(options).toHaveLength(6);
+ expect(options[0]).toHaveValue("off");
+ expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument();
});
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+ it("renders a persisted level as selected when thinking is on", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() },
+ });
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
});
+ it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => {
+ // thinking off is a SEPARATE axis: even with a persisted effort level, the
+ // selector shows "off" while thinking is disabled.
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() },
+ });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ // the level option is still present (restored on an off→on toggle)
+ expect(
+ within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }),
+ ).toBeInTheDocument();
+ });
+
it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
ok: true as const,
- reasoningEffort: level,
+ selection,
}));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1127,11 +1638,35 @@ describe("ReasoningEffortSelector", () => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
});
- it("a failed save shows the error and reverts to the persisted value", async () => {
+ it("selecting 'off' saves the separate disable signal (not a level)", async () => {
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ selection,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "high", persistedThinking: null, save },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ // "off" is the separate thinking-disable signal — NOT a zero-effort level.
+ expect(save).toHaveBeenCalledWith("off");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ });
+
+ it("a failed save shows the error and reverts to the persisted selection", async () => {
const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "low", persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1142,24 +1677,215 @@ describe("ReasoningEffortSelector", () => {
});
it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined;
const save = vi.fn(
() =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ new Promise<{ ok: true; selection: "max" }>((resolve) => {
resolveSave = resolve;
}),
);
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
- resolveSave?.({ ok: true, reasoningEffort: "max" });
+ resolveSave?.({ ok: true, selection: "max" });
await vi.waitFor(() => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
});
});
});
+
+describe("ChatView — cache timer", () => {
+ const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 };
+ function step(stepId: string) {
+ return { stepId: stepId as StepId, usage, decodeMs: 100 };
+ }
+ function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry {
+ const steps = stepIds.map(step);
+ return { turnId, steps, total: { turnId, usage, steps } };
+ }
+ function exchange(turnId: string, base: number): RenderedChunk[] {
+ return [
+ {
+ seq: base,
+ role: "user",
+ chunk: { type: "text", text: `q-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ {
+ seq: base + 1,
+ role: "assistant",
+ chunk: { type: "text", text: `a-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ ];
+ }
+ function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk {
+ return {
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: `call-${stepId}`,
+ toolName: "read_file",
+ input: {},
+ stepId: stepId as StepId,
+ },
+ provisional: false,
+ turnId,
+ };
+ }
+ function state(partial: Partial<CacheTimerState>): CacheTimerState {
+ return { ...initialCacheTimerState(), ...partial };
+ }
+
+ it("renders nothing when the feature is off (cacheTimer null)", () => {
+ render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } });
+ expect(screen.queryByTestId("cache-timer-live")).toBeNull();
+ expect(screen.queryByTestId("cache-timer-stamp")).toBeNull();
+ });
+
+ it("shows a live timer under executing tools, counting up to 10+ min", async () => {
+ vi.useFakeTimers();
+ try {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 1,
+ pending: new Set(["call-t1#0"]),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ const row = screen.getByTestId("cache-timer-tools");
+ expect(row).toHaveTextContent(/Running tools/);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30");
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("hides the live tools row for a step with no tool calls", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 0,
+ pending: new Set(),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("stamps a finished step gap on that step's line, next to its cache badge", () => {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }),
+ },
+ });
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("1:05");
+ // Sized to match the step's (xs) cache badge.
+ expect(stamp).toHaveClass("badge-xs");
+ expect(stamp).not.toHaveClass("badge-sm");
+ // Same line as the step's cache badge.
+ expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge"));
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: "t2", startedAt: Date.now() },
+ turnStamps: new Map([["t1", 11 * 60_000]]),
+ }),
+ },
+ });
+ const live = screen.getByTestId("cache-timer-live");
+ expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ expect(live).toHaveClass("badge-sm");
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("10+ min");
+ expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/);
+ });
+
+ it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => {
+ // Regression: the badge's effect once READ the `current` state it wrote, so
+ // with a real (always-advancing) clock it re-ran itself until Svelte threw
+ // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late.
+ let calls = 0;
+ const now = () => {
+ calls++;
+ return 1_000_000 + calls;
+ };
+ render(CacheTimerBadge, { props: { since: 1_000_000, now } });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(calls).toBeLessThan(5);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ });
+
+ it("renders the turn's metrics alongside a live turn timer", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [turn("t1")],
+ cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }),
+ },
+ });
+ expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument();
+ expect(screen.getByText("a-t1")).toBeInTheDocument();
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/);
+ });
+
+ it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 },
+ }),
+ },
+ });
+ const badges = screen.getAllByTestId("cache-timer-live");
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveTextContent("2:00");
+ expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ });
+});
diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte
new file mode 100644
index 0000000..9e6f96d
--- /dev/null
+++ b/src/features/chat/ui/CacheTimerBadge.svelte
@@ -0,0 +1,70 @@
+<script lang="ts">
+ /**
+ * One cache-timer badge. LIVE when given `since` (counts up from that epoch
+ * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED
+ * when given `elapsedMs` (a finished gap — static). Formatting lives in
+ * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this
+ * badge re-renders each second, not the transcript.
+ */
+ import { untrack } from "svelte";
+ import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics";
+
+ let {
+ since,
+ elapsedMs,
+ size = "sm",
+ now = Date.now,
+ }: {
+ /** Live: epoch ms the gap started. */
+ since?: number;
+ /** Stamped: the gap's final duration (wins over `since`). */
+ elapsedMs?: number;
+ /** Badge size — match the neighbouring cache badge (`xs` on step lines). */
+ size?: "xs" | "sm";
+ /** Wall clock (injected for tests). */
+ now?: () => number;
+ } = $props();
+
+ // Initial read only (the effect below keeps it current while live).
+ let current = $state(untrack(() => now()));
+
+ // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current`
+ // (it writes it): reading its own write makes the effect re-run itself on
+ // every clock change → `effect_update_depth_exceeded`, which aborts the whole
+ // reactive flush and stalls the transcript (turn data rendering late).
+ $effect(() => {
+ if (elapsedMs !== undefined || since === undefined) return;
+ const start = since;
+ const clock = now;
+ const t0 = clock();
+ current = t0;
+ if (t0 - start >= CACHE_TIMER_MAX_MS) return;
+ const id = setInterval(() => {
+ const t = clock();
+ current = t;
+ if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id);
+ }, 1000);
+ return () => clearInterval(id);
+ });
+
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
+
+ const live = $derived(elapsedMs === undefined);
+ const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current)));
+</script>
+
+<span
+ class="badge font-mono tabular-nums {badgeClass[view.level]}"
+ class:badge-xs={size === "xs"}
+ class:badge-sm={size === "sm"}
+ class:badge-outline={!live}
+ title={live
+ ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle."
+ : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."}
+ data-testid={live ? "cache-timer-live" : "cache-timer-stamp"}
+ >{view.label}</span
+>
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index e67ca5b..84e66d8 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -2,14 +2,19 @@
import type { TurnProviderRetryEvent } from "@dispatch/wire";
import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index";
import {
+ type CacheTimerState,
interleaveTurnMetrics,
+ lastTurnRowIndex,
viewCacheRate,
viewExpectedCache,
viewStepMetrics,
viewTurnMetrics,
type TurnMetricsEntry,
} from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import { Markdown, type TextFileClient } from "../../markdown";
+ import CacheTimerBadge from "./CacheTimerBadge.svelte";
+ import LazyCollapse from "./LazyCollapse.svelte";
+ import ToolCard from "./ToolCard.svelte";
const badgeClass = {
success: "badge-success",
@@ -19,14 +24,19 @@
let {
chunks,
+ recovering = false,
turnMetrics = [],
hasEarlier = false,
onShowEarlier,
thinkingKeyBase = 0,
providerRetry = null,
apiBaseUrl = "",
+ cwd = null,
+ textFiles = undefined,
+ cacheTimer = null,
}: {
chunks: readonly RenderedChunk[];
+ recovering?: boolean;
turnMetrics?: readonly TurnMetricsEntry[];
/** Earlier history is unloaded (chat limit) and can be paged back in. */
hasEarlier?: boolean;
@@ -53,10 +63,27 @@
* "" (root-relative — a browser resolves `/images/…` against its origin).
*/
apiBaseUrl?: string;
+ /**
+ * The active conversation's working directory (null when unset). Relative
+ * Markdown image srcs (`![x](shot.png)`) resolve against it; local absolute
+ * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`.
+ */
+ cwd?: string | null;
+ /** Context-bound, injected live-file display port (never model history). */
+ textFiles?: TextFileClient | undefined;
+ /**
+ * The cache timer (gaps between model requests): a running step gap shows
+ * live at the bottom (under the executing tools), a running turn gap live
+ * on that turn's metrics row; finished gaps are stamped on their step /
+ * turn metrics line. `null` (the default) hides it entirely — the caller
+ * gates on the model.
+ */
+ cacheTimer?: CacheTimerState | null;
} = $props();
// True while a show-earlier page-in is awaited (disables the button).
let loadingEarlier = $state(false);
+ let failedImages = $state<string[]>([]);
async function showEarlier() {
if (!onShowEarlier || loadingEarlier) return;
@@ -68,10 +95,36 @@
}
}
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks, recovering));
const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most
+ // recent turn's row (when no user message follows it).
+ const seededTurnRow = $derived(
+ cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null
+ ? lastTurnRowIndex(rows)
+ : null,
+ );
+
+ /** Start of the running turn gap if it belongs on this turn row, else undefined. */
+ function liveTurnSince(turnId: string, rowIndex: number): number | undefined {
+ const r = cacheTimer?.running;
+ if (r?.kind !== "turn") return undefined;
+ if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) {
+ return r.startedAt;
+ }
+ return undefined;
+ }
+
+ // A running step gap with tool calls → the live timer under the executing tools.
+ const liveStepGap = $derived(
+ cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0
+ ? cacheTimer.running
+ : null,
+ );
+
+ // Tool cards use toolCallId keys across pending/result/seal and orphan page-in.
// Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
// survives the provisional→committed (seq null → seq N) transition, so the
// collapse's open/close state is NOT lost when a turn seals. The ordinal
@@ -88,8 +141,8 @@
}
const group = row.group;
let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
+ if (group.kind === "tool") {
+ key = `t${group.entry.toolCallId}`;
} else if (group.chunk.chunk.type === "thinking") {
key = `think${thinking++}`;
} else if (group.chunk.seq != null) {
@@ -102,6 +155,12 @@
});
</script>
+{#if recovering}
+ <p role="status" class="px-4 py-2 text-sm text-base-content/70">
+ Recovering conversation history… Omitted tool input/output is unavailable until persisted.
+ </p>
+{/if}
+
{#snippet chunkRow(rendered: RenderedChunk)}
{#if rendered.role === "user"}
<!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
@@ -114,13 +173,22 @@
{#if rendered.chunk.type === "text"}
<p>{rendered.chunk.text}</p>
{:else if rendered.chunk.type === "image"}
- <img
- src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
- alt={rendered.chunk.mimeType ?? "pasted image"}
- loading="lazy"
- decoding="async"
- class="max-h-80 max-w-full rounded"
- />
+ {#if failedImages.includes(rendered.chunk.url)}
+ <span class="text-sm opacity-70">image expired</span>
+ {:else}
+ <img
+ src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
+ alt={rendered.chunk.mimeType ?? "pasted image"}
+ loading="lazy"
+ decoding="async"
+ onerror={() => {
+ if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) {
+ failedImages = [...failedImages, rendered.chunk.url];
+ }
+ }}
+ class="max-h-80 max-w-full rounded"
+ />
+ {/if}
{/if}
</div>
</div>
@@ -130,83 +198,15 @@
while generating, then "Thoughts" with no dots once complete. -->
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
<div class="chat-bubble w-full bg-transparent">
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle thoughts" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <LazyCollapse label="Toggle thoughts">
+ {#snippet title()}
<span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
{#if rendered.streaming}
<span class="loading loading-dots loading-sm" aria-label="Generating"></span>
{/if}
- </div>
- <div class="collapse-content">
- <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
- </div>
- </div>
- </div>
- </div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "tool-call"}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
- <span>{rendered.chunk.toolName}</span>
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(
- rendered.chunk.input,
- null,
- 2,
- )}</pre>
- </div>
- </div>
- {:else}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool result" />
- <div
- class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"
- class:text-error={rendered.chunk.isError}
- >
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
- <span>{rendered.chunk.toolName}</span>
- {#if rendered.chunk.isError}
- <span class="badge badge-error badge-xs">error</span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
- </div>
- </div>
- {/if}
+ {/snippet}
+ <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p>
+ </LazyCollapse>
</div>
</div>
{:else}
@@ -215,7 +215,12 @@
<div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
<div class="chat-bubble w-full bg-transparent">
{#if rendered.chunk.type === "text"}
- <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
+ <Markdown
+ text={rendered.chunk.text}
+ streaming={rendered.streaming ?? false}
+ image={{ apiBase: apiBaseUrl, cwd }}
+ {textFiles}
+ />
{:else if rendered.chunk.type === "error"}
<div class="text-error" role="alert">
{rendered.chunk.message}
@@ -245,17 +250,33 @@
</button>
</div>
{/if}
- {#each keyedRows as { row, key } (key)}
+ {#each keyedRows as { row, key }, i (key)}
+ <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint
+ for rows outside the viewport (see app.css). -->
+ <div class="chat-row">
{#if row.kind === "step-metrics"}
{@const sv = viewStepMetrics(row.step, row.index)}
+ {@const stepCache = viewCacheRate(row.step.usage)}
+ {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="text-xs opacity-70">
- {sv.label} · {sv.tokensLabel}
- {#if sv.tps}
- · {sv.tps}{/if}
- {#if sv.genTotal}
- · {sv.genTotal}{/if}
+ <div class="flex flex-wrap items-center gap-x-1 text-xs">
+ <span class="opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps}
+ · {sv.tps}{/if}
+ {#if sv.ttft}
+ · {sv.ttft} to first token{/if}
+ ·
+ </span>
+ <span class="opacity-70">cache:</span>
+ <span
+ class="badge badge-xs {badgeClass[stepCache.level]}"
+ data-testid="step-cache-badge">{stepCache.pct}%</span
+ >
+ {#if stepStamp !== undefined}
+ <CacheTimerBadge elapsedMs={stepStamp} size="xs" />
+ {/if}
</div>
</div>
</div>
@@ -263,12 +284,14 @@
{@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
{@const lastCache = viewCacheRate(row.turn.usage)}
{@const chatCache = viewCacheRate(row.cumulativeUsage)}
- {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
+ {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)}
+ {@const turnLive = liveTurnSince(row.turn.turnId, i)}
+ {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)}
<div class="chat chat-start">
<div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
<div class="flex flex-col gap-1 text-xs">
<div class="opacity-70">
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
+ {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown}
{#if turnView.tps}
· {turnView.tps}{/if}
{#if turnView.duration}
@@ -289,6 +312,11 @@
<span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
</span>
{/if}
+ {#if turnLive !== undefined}
+ <CacheTimerBadge since={turnLive} />
+ {:else if turnStamp !== undefined}
+ <CacheTimerBadge elapsedMs={turnStamp} />
+ {/if}
</div>
</div>
</div>
@@ -296,54 +324,27 @@
{:else if row.group.kind === "single"}
{@render chunkRow(row.group.chunk)}
{:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry (entry.call.toolCallId)}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg
- class="h-4 w-4 opacity-60"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- ><path
- d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
- /></svg
- >
- <span>{entry.call.toolName}</span>
- {#if entry.result?.isError}
- <span class="badge badge-error badge-xs">error</span>
- {:else if entry.result === null}
- <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(
- entry.call.input,
- null,
- 2,
- )}</pre>
- {#if entry.result}
- <pre
- class="mt-1 max-h-96 overflow-auto text-xs"
- class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </div>
- </div>
- {/each}
- </div>
+ <!-- Each call owns one bubble; the matching result is folded into its card. -->
+ <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble min-w-0 w-full bg-transparent">
+ <ToolCard entry={row.group.entry} />
</div>
</div>
{/if}
+ </div>
{/each}
+ {#if liveStepGap}
+ <!-- Live cache timer while the last step's tools execute; stamped onto that
+ step's metrics line once they return (or the next step starts). -->
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools">
+ <span class="opacity-70">Running tools</span>
+ <CacheTimerBadge since={liveStepGap.startedAt} size="xs" />
+ </div>
+ </div>
+ </div>
+ {/if}
{#if providerRetry}
{@const rv = viewProviderRetry(providerRetry)}
<!-- Transient yellow warning: a provider error is being retried with backoff.
diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts
new file mode 100644
index 0000000..157ccac
--- /dev/null
+++ b/src/features/chat/ui/ChatView.test.ts
@@ -0,0 +1,558 @@
+import type { StepId } from "@dispatch/wire";
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { RenderedChunk } from "../../../core/chunks";
+import ChatView from "./ChatView.svelte";
+
+/** Collapsed card bodies render lazily — open every card so its body mounts. */
+async function openAllCollapses(container: HTMLElement): Promise<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]');
+ for (const box of boxes) {
+ if (!box.checked) await userEvent.click(box);
+ }
+}
+
+// Defensive rendering: a conversation transcript can contain duplicate
+// `toolCallId`s (persisted provider double-emission). The
+// grouped render path MUST dedupe them so the keyed {#each} never throws
+// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the
+// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1.
+describe("ChatView — duplicate toolCallIds", () => {
+ it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ // The duplicate-emission pattern: the same ids re-emitted immediately after.
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 5,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 6,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "b",
+ toolName: "list_dir",
+ content: "contents-of-b",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ // Duplicate results too — absorbed into the corresponding cards.
+ {
+ seq: 7,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 8,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "b",
+ toolName: "list_dir",
+ content: "contents-of-b",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
+
+ // No each_key_duplicate crash. Calls render as independent collapsible cards —
+ // exactly ONE per unique toolCallId (deduped), not 4.
+ const collapses = container.querySelectorAll(".collapse");
+ expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
+
+ // Both unique tool names shown; each result rendered once (deduped).
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-b")).toBeInTheDocument();
+ });
+});
+
+describe("ChatView — lazy collapse bodies", () => {
+ it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "x",
+ toolName: "run_shell",
+ content: "HUGE-OUTPUT",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ // English display name; the raw wire name stays available as the tooltip.
+ expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell");
+ expect(screen.queryByText("HUGE-OUTPUT")).toBeNull();
+
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
+
+ await userEvent.click(box); // close: body stays mounted (close animation)
+ expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument();
+ expect(container.querySelectorAll(".chat-row")).toHaveLength(1);
+ });
+});
+
+function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] {
+ const call: RenderedChunk = {
+ seq: committed ? 1 : null,
+ role: "assistant",
+ provisional: !committed,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ input: { command: "bun run test" },
+ },
+ };
+ return content === null
+ ? [call]
+ : [
+ call,
+ {
+ seq: committed ? 2 : null,
+ role: "tool",
+ provisional: !committed,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "shell-1",
+ toolName: "run_shell",
+ content,
+ isError,
+ },
+ },
+ ];
+}
+
+describe("ChatView — tool cards", () => {
+ it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => {
+ const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(container.querySelectorAll(".collapse")).toHaveLength(1);
+ expect(screen.getByText("bun run test")).toBeInTheDocument();
+ expect(screen.getByText("→ 2 lines")).toBeInTheDocument();
+ expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ expect(box).not.toBeChecked();
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ expect(screen.getByText("first second")).toBeInTheDocument();
+ expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull();
+ });
+ it("transitions pending → result → sealed without losing open state", async () => {
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } });
+ expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm");
+ expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByText("running…")).toBeInTheDocument();
+ await rerender({ chunks: shellChunks("done") });
+ expect(screen.queryByLabelText("Running")).toBeNull();
+ expect(screen.queryByText("→ running…")).toBeNull();
+ expect(screen.getByLabelText("Succeeded")).toBeInTheDocument();
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ await rerender({ chunks: shellChunks("done", false, true) });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("done")).toBeInTheDocument();
+ });
+ it("keeps errors collapsed with a failure icon and tints the expanded output", async () => {
+ const { container } = render(ChatView, {
+ props: { chunks: shellChunks("Permission denied", true) },
+ });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ expect(box).not.toBeChecked();
+ expect(screen.getByLabelText("Failed")).toHaveClass("text-error");
+ expect(screen.getByLabelText("Failed")).toHaveTextContent("✗");
+ expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error");
+ expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied");
+ expect(container.querySelector("pre")).toBeNull();
+ await userEvent.click(box);
+ expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error");
+ });
+ it("renders an orphan result as the same card and retains state when its call is paged back in", async () => {
+ const chunks = shellChunks("contents", false, true);
+ const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } });
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1);
+ expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history");
+ expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60");
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ expect(screen.getByRole("note")).toHaveTextContent(
+ "This call is earlier in the conversation. Show earlier messages to see it.",
+ );
+ expect(screen.getByRole("note")).toHaveClass("italic", "text-base-content/60");
+ expect(screen.queryByRole("heading", { name: "Call" })).toBeNull();
+ expect(screen.getByText("contents")).toBeInTheDocument();
+ await rerender({ chunks });
+ expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box);
+ expect(box).toBeChecked();
+ expect(screen.getByText("$ bun run test")).toBeInTheDocument();
+ });
+ it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => {
+ const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n");
+ const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } });
+ const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" });
+ await userEvent.click(box);
+ const response = screen.getByRole("region", { name: "Response" });
+ expect(response).toHaveTextContent("output-1");
+ expect(response).toHaveTextContent("output-6");
+ expect(response).not.toHaveTextContent("output-7");
+ expect(response).toHaveTextContent("output-8");
+ expect(response).toHaveTextContent("output-13");
+ const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" });
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(toggle);
+ expect(response).toHaveTextContent("output-7");
+ expect(response.querySelectorAll("pre")).toHaveLength(1);
+ expect(response.querySelector("pre")?.textContent).toBe(output);
+ const fewer = screen.getByRole("button", { name: "Show fewer lines" });
+ expect(fewer).toHaveClass("btn-soft", "btn-xs");
+ expect(fewer.parentElement).toHaveClass("sticky-controls");
+ const fullOutput = response.querySelector("pre");
+ if (fullOutput === null) throw new Error("expected full output");
+ expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" }));
+ expect(response).not.toHaveTextContent("output-7");
+ expect(box).toBeChecked();
+ // The separate output-fold state also survives sealing.
+ await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" }));
+ expect(response).toHaveTextContent("output-7");
+ await rerender({ chunks: shellChunks(output, false, true) });
+ expect(box).toBeChecked();
+ expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute(
+ "aria-expanded",
+ "true",
+ );
+ expect(response).toHaveTextContent("output-7");
+ });
+ it("shows 12 output lines without an inner toggle", async () => {
+ const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n");
+ render(ChatView, { props: { chunks: shellChunks(output) } });
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" }));
+ expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull();
+ expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12");
+ });
+ it("renders edit details and treats tool output as text, not executable markup", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ input: { path: "a.ts", oldString: "before", newString: "after\nsecond" },
+ },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "edit",
+ toolName: "edit_file",
+ content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>',
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("+2")).toHaveClass("text-success");
+ expect(screen.getByText("−1")).toHaveClass("text-error");
+ expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" }));
+ // Unified diff: one tinted row per line, each with its own −/+ gutter marker.
+ const diff = screen.getByRole("group", { name: "Edit diff" });
+ const removed = diff.querySelectorAll(".edit-diff-del");
+ const added = diff.querySelectorAll(".edit-diff-add");
+ expect(Array.from(removed, (r) => r.textContent?.trim())).toEqual(["−before"]);
+ expect(Array.from(added, (r) => r.textContent?.trim())).toEqual(["+after", "+second"]);
+ expect(container.querySelector("script")).toBeNull();
+ expect(container).toHaveTextContent("<script>alert(1)</script>");
+ });
+});
+
+it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "issue",
+ toolName: "mcp__github__create_issue",
+ input: {
+ owner: "realtradam",
+ repo: "frontend",
+ title: "Tool cards",
+ labels: ["ui", "chat"],
+ options: { nested: true },
+ },
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call Github: Create issue" }),
+ );
+ for (const name of ["Call", "Response"]) {
+ expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content");
+ }
+ const call = screen.getByRole("region", { name: "Call" });
+ expect(call.querySelector("dl")).toHaveClass("call-fields");
+ expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85");
+ const labels = screen.getByText('["ui", "chat"]');
+ expect(labels.tagName).toBe("PRE");
+ expect(labels.textContent).not.toContain("\n");
+ expect(container.querySelectorAll("dt")).toHaveLength(5);
+ expect(container.querySelectorAll("dd")).toHaveLength(5);
+ expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true');
+});
+
+it("removes a pending edit's diffstat when it fails, without hiding the attempted edit", async () => {
+ const call: RenderedChunk = {
+ seq: null,
+ role: "assistant",
+ provisional: true,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "failed-edit",
+ toolName: "edit_file",
+ input: { path: "src/missing.ts", oldString: "foo", newString: "bar" },
+ },
+ };
+ const result: RenderedChunk = {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "failed-edit",
+ toolName: "edit_file",
+ content: "Error: oldString not found",
+ isError: true,
+ },
+ };
+ const { container, rerender } = render(ChatView, { props: { chunks: [call] } });
+ expect(screen.getByText("+1")).toBeInTheDocument();
+ expect(screen.getByText("−1")).toBeInTheDocument();
+ expect(screen.getByText("→ running…")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" }));
+ await rerender({ chunks: [call, result] });
+ expect(screen.queryByText("+1")).toBeNull();
+ expect(screen.queryByText("−1")).toBeNull();
+ expect(screen.queryByText("→ running…")).toBeNull();
+ expect(container.querySelector(".tool-header")).toHaveTextContent("src/missing.ts");
+ expect(container.querySelector(".tool-header")).toHaveTextContent("→ failed");
+ expect(screen.getByText("foo")).toBeInTheDocument();
+ expect(screen.getByText("bar")).toBeInTheDocument();
+ expect(screen.getByText("Error: oldString not found")).toBeInTheDocument();
+});
+
+it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} },
+ },
+ {
+ seq: 2,
+ role: "tool",
+ provisional: false,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "m",
+ toolName: "list_agent_models",
+ content: "Available named agents (3)",
+ isError: false,
+ },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ expect(screen.getByText("List agent models")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }),
+ );
+ expect(screen.getByText("No arguments")).toBeInTheDocument();
+ expect(container).not.toHaveTextContent("{}");
+});
+
+it("renders a per-tool Phosphor icon in the header", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "s",
+ toolName: "run_shell",
+ input: { command: "ls" },
+ },
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ provisional: false,
+ chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } },
+ },
+ ];
+ const { container } = render(ChatView, { props: { chunks } });
+ const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']"));
+ expect(icons).toHaveLength(2);
+ // Different tools → different glyphs.
+ expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML);
+});
+
+it("replaces failed persisted images with an expired placeholder, without marking inline images expired", async () => {
+ const chunks: RenderedChunk[] = ["/images/conv/gone.png", "data:image/png;base64,AAAA"].map(
+ (url, i) => ({ seq: i + 1, role: "user", chunk: { type: "image", url }, provisional: false }),
+ );
+ render(ChatView, { chunks, apiBaseUrl: "https://backend.test" });
+ const [stored, inline] = screen.getAllByRole("img");
+ if (!stored || !inline) throw new Error("expected both images");
+ expect(stored).toHaveAttribute("src", "https://backend.test/images/conv/gone.png");
+ await fireEvent.error(inline);
+ expect(screen.queryByText("image expired")).toBeNull();
+ await fireEvent.error(stored);
+ expect(screen.getByText("image expired")).toBeInTheDocument();
+ expect(screen.getAllByRole("img")).toEqual([inline]);
+});
+
+it("shows omitted in-progress tool input as recovering/unavailable, never No arguments", async () => {
+ const { container, rerender } = render(ChatView, {
+ recovering: true,
+ chunks: [
+ {
+ seq: null,
+ role: "tool",
+ provisional: true,
+ chunk: {
+ type: "tool-result",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ content: "real output",
+ isError: false,
+ },
+ },
+ ],
+ });
+ expect(screen.getByRole("status").textContent).toContain("unavailable until persisted");
+ expect(screen.getByText("Recovering tool input…")).toBeTruthy();
+ await openAllCollapses(container);
+ expect(screen.getByText(/Tool input is unavailable until it is persisted/)).toBeTruthy();
+ expect(screen.queryByText("No arguments")).toBeNull();
+ expect(screen.getByText("real output")).toBeTruthy();
+ await rerender({
+ recovering: false,
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ provisional: false,
+ chunk: {
+ type: "tool-call",
+ toolCallId: "missing",
+ toolName: "run_shell",
+ input: { command: "recovered command" },
+ },
+ },
+ ],
+ });
+ expect(screen.queryByRole("status")).toBeNull();
+ expect(screen.queryByText("Recovering tool input…")).toBeNull();
+});
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index 04c28cd..cd451bc 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,5 +1,8 @@
<script lang="ts">
import type { ImageInput } from "@dispatch/wire";
+ import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode";
+ import { stopButtonLabels } from "../logic/composer";
+ import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress";
import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
const FALLBACK_CONTEXT_WINDOW = 1_000_000;
@@ -8,20 +11,56 @@
const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp";
/** Reject images larger than this before base64-encoding (keeps payloads sane). */
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
+ /** Tooltip on the per-image compress toggle (PNG → JPEG + resize). */
+ const COMPRESS_TOOLTIP = `Re-encode PNG to JPEG and resize to ${MAX_IMAGE_EDGE_PX}px (smaller upload). Unchecked sends the original bytes.`;
- /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */
+ /**
+ * A staged image awaiting send: a stable id + everything needed to resolve
+ * the `ImageInput` that will actually be sent. The ORIGINAL `File` (and its
+ * data URL) is kept so the per-image compress toggle is reversible until
+ * send; the async conversion result is cached so flipping the toggle never
+ * re-encodes.
+ */
interface StagedImage {
readonly id: string;
- readonly input: ImageInput;
+ /** The original untouched File — sent verbatim when compress is off. */
+ readonly file: File;
+ /** Data URL of the original (staged immediately — conversion is async). */
+ readonly originalUrl: string;
+ readonly originalMimeType: string | undefined;
+ /** Whether the converted (JPEG) variant is used for send. Default ON. */
+ compress: boolean;
+ /** Cached conversion (data URL of the JPEG); `null` = none/failed/n.a. */
+ converted: { url: string } | null;
+ }
+
+ /**
+ * The `ImageInput` this staged image will actually SEND: the converted JPEG
+ * (data URL + `image/jpeg`) when compress is on AND a conversion exists,
+ * otherwise the original untouched bytes.
+ */
+ function imageInput(img: StagedImage): ImageInput {
+ if (img.compress && img.converted !== null) {
+ return { url: img.converted.url, mimeType: "image/jpeg" };
+ }
+ const { originalUrl: url, originalMimeType: mimeType } = img;
+ return { url, ...(mimeType ? { mimeType } : {}) };
+ }
+
+ /** Whether the compress toggle is rendered for this image (convertible formats only). */
+ function compressible(img: StagedImage): boolean {
+ return shouldConvertImage(img.originalMimeType);
}
let {
onSend,
onQueue,
onStop,
+ encodeJpeg = convertToJpeg,
contextSize = undefined,
contextWindow = undefined,
status = "idle",
+ queuedMessageCount = undefined,
}: {
/**
* Send a message (start a turn via `chat.send`). Carries any staged images
@@ -33,12 +72,17 @@
/**
* Enqueue a steering message (`chat.queue`). When provided AND the status
* is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. Steering is text-only —
- * it never carries images (a mid-turn injection has no image surface).
+ * in-flight turn instead of starting a new one. Carries staged images just
+ * like onSend; without this callback, in-flight input stays staged.
*/
- onQueue?: (text: string) => void;
+ onQueue?: (text: string, images?: ImageInput[]) => void;
/** Stop the in-flight generation (`POST /conversations/:id/stop`). */
onStop?: () => void;
+ /**
+ * Injected compression edge (a `File` in, a maybe-smaller JPEG out).
+ * Defaults to the real canvas converter; tests stub this outermost edge.
+ */
+ encodeJpeg?: ImageJpegEncoder;
// Current context occupancy — updated progressively during a turn (the
// latest step's input+output) and finalized to the turn's `contextSize` on
// seal, or `undefined` when unknown — the status bar then shows
@@ -53,6 +97,8 @@
* `running` for the send button (steer/stop).
*/
status?: ComposerStatus;
+ /** Current conversation's queued snapshot size; undefined when unavailable. */
+ queuedMessageCount?: number | undefined;
} = $props();
export type ComposerStatus = "idle" | "running" | "queued" | "error";
@@ -65,25 +111,23 @@
const hasText = $derived(text.trim().length > 0);
const hasImages = $derived(images.length > 0);
- const canSend = $derived(hasText || hasImages);
+ const inFlight = $derived(status === "running" || status === "queued");
+ const canSend = $derived((hasText || hasImages) && (!inFlight || onQueue !== undefined));
const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
const usage = $derived(computeContextUsage(contextSize, effectiveMax));
// One button, three modes:
// - idle → "Send" (starts a turn via chat.send)
- // - running/queued + text → "Queue" (steers via chat.queue — text only)
+ // - running/queued + text/images → "Queue" (steers via chat.queue)
// - running/queued + empty → "Stop" (aborts via POST /stop)
// (`queued` behaves like `running` — the turn is in flight, just waiting for a
// concurrency slot; the user can still steer or stop it.)
- // Steering never carries images: when running with images staged but no text,
- // the images stay staged (queue is text-only). Images-without-text while running
- // is an unusual case that still sends (the server auto-starts/resolves).
- const inFlight = $derived(status === "running" || status === "queued");
const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop";
- if (inFlight && hasText && onQueue !== undefined) return "queue";
+ if (inFlight) return "queue";
return "send";
});
+ const stopButton = $derived(stopButtonLabels(queuedMessageCount));
const placeholder = $derived(
status === "queued"
? "Queued for a slot…"
@@ -120,8 +164,8 @@
resize();
});
- /** Read a File into a base64 data URL (`data:image/…;base64,…`). */
- function fileToDataUrl(file: File): Promise<string> {
+ /** Read a File/Blob into a base64 data URL (`data:image/…;base64,…`). */
+ function fileToDataUrl(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -142,10 +186,51 @@
// Prefer the file's declared MIME; fall back to the data URL's prefix.
const mimeType = file.type || undefined;
const id = `img-${Date.now()}-${imgSeq++}`;
- images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }];
+ images = [
+ ...images,
+ {
+ id,
+ file,
+ originalUrl: url,
+ originalMimeType: mimeType,
+ compress: true, // compress is ON by default for every staged image
+ converted: null,
+ },
+ ];
+ // Convert async in the background — staging is never blocked on it; the
+ // staged input stays the original until the converted URL arrives.
+ if (shouldConvertImage(mimeType)) {
+ void runConversion(id);
+ }
return true;
}
+ /**
+ * Run the (injected) compression edge for a staged image and cache the
+ * result. ANY failure (rejecting encoder included) resolves to no
+ * conversion → the original is sent; compression never breaks staging. If
+ * the image was removed while converting, the result is dropped.
+ */
+ async function runConversion(id: string): Promise<void> {
+ const staged = images.find((img) => img.id === id);
+ if (staged === undefined) return;
+ let result: ConvertedJpeg | null = null;
+ try {
+ result = await encodeJpeg(staged.file);
+ } catch {
+ result = null; // fall back to the original — never block or break sending
+ }
+ const entry = images.find((img) => img.id === id);
+ if (entry === undefined) return; // removed mid-conversion
+ entry.converted = result === null ? null : { url: await fileToDataUrl(result.blob) };
+ }
+
+ /** Flip the per-image compress toggle (reversible until send). */
+ function setCompress(id: string, compress: boolean): void {
+ const entry = images.find((img) => img.id === id);
+ if (entry !== undefined) entry.compress = compress;
+ }
+
function removeImage(id: string): void {
images = images.filter((img) => img.id !== id);
}
@@ -200,15 +285,15 @@
const trimmed = text.trim();
// Allow a send with images even when text is empty (an image-only turn).
if (trimmed.length === 0 && !hasImages) return;
- if (buttonMode === "queue") {
- // Steering is text-only — never forward images.
- onQueue?.(trimmed);
+ const toSend = hasImages ? images.map((img) => imageInput(img)) : undefined;
+ if (inFlight) {
+ if (onQueue === undefined) return; // Keep staged input if no queue port is wired.
+ onQueue(trimmed, toSend);
} else {
- const toSend: ImageInput[] | undefined = hasImages
- ? images.map((img) => img.input)
- : undefined;
onSend(trimmed, toSend);
}
+ // Clear only after handing the complete input to a callback. WS is still
+ // fire-and-forget: this does not imply backend storage has completed.
text = "";
images = [];
}
@@ -239,7 +324,7 @@
<!-- Top bar: expanding textarea + image-attach button + single context-aware button -->
<div class="flex items-end gap-2 px-4 pt-3 pb-2">
<div
- class="flex-1"
+ class="min-w-0 flex-1"
onpaste={handlePaste}
class:border-2={dragOver}
class:border-primary={dragOver}
@@ -293,10 +378,11 @@
<button
class="btn btn-error w-20 shrink-0"
type="button"
- aria-label="Stop generation"
+ aria-label={stopButton.ariaLabel}
+ title={stopButton.title}
onclick={() => onStop?.()}
>
- Stop
+ {stopButton.text}
</button>
{:else}
<button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}>
@@ -310,9 +396,10 @@
<div class="flex flex-wrap gap-2 px-4 pb-1">
{#each images as img (img.id)}
<div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300">
+ <!-- Preview what will actually be sent (the converted JPEG when compress is on). -->
<img
- src={img.input.url}
- alt={img.input.mimeType ?? "staged image"}
+ src={imageInput(img).url}
+ alt={imageInput(img).mimeType ?? "staged image"}
class="h-full w-full object-cover"
/>
<button
@@ -335,6 +422,22 @@
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
+ <!-- Per-image compress toggle (convertible formats only; reversible until send). -->
+ {#if compressible(img)}
+ <label
+ class="absolute bottom-0 left-0 flex items-center gap-1 bg-base-100/80 px-1 py-0.5 text-[10px] leading-none"
+ title={COMPRESS_TOOLTIP}
+ >
+ <input
+ type="checkbox"
+ class="checkbox checkbox-xs"
+ aria-label="Compress image"
+ checked={img.compress}
+ onchange={(e) => setCompress(img.id, e.currentTarget.checked)}
+ />
+ <span class="whitespace-nowrap">Compress</span>
+ </label>
+ {/if}
</div>
{/each}
</div>
diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte
new file mode 100644
index 0000000..3ce34dd
--- /dev/null
+++ b/src/features/chat/ui/LazyCollapse.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import type { Snippet } from "svelte";
+
+ /**
+ * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside
+ * `.collapse-content` is created until the user first opens it. A long turn
+ * has hundreds of collapsed tool/thinking cards, each holding a full JSON
+ * input / tool output / thought — building all of that DOM up front (only to
+ * be hidden) dominated first paint on slow devices. Once opened the body
+ * stays mounted, so the close animation and any inner scroll state work as
+ * before. Same markup + classes as the inline collapses it replaces.
+ */
+ let {
+ label,
+ titleClass = "",
+ title,
+ children,
+ }: {
+ /** aria-label of the toggle checkbox. */
+ label: string;
+ /** Extra classes on the `.collapse-title` row. */
+ titleClass?: string;
+ title: Snippet;
+ children: Snippet;
+ } = $props();
+
+ let open = $state(false);
+ let everOpened = $state(false);
+ $effect(() => {
+ if (open) everOpened = true;
+ });
+</script>
+
+<!-- `.inset-well` (app.css): the recessed panel shared by thinking/tool blocks
+ and the sidebar views. -->
+<div class="collapse inset-well w-full text-sm">
+ <input type="checkbox" aria-label={label} bind:checked={open} />
+ <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}">
+ {@render title()}
+ </div>
+ <div class="collapse-content">
+ {#if open || everOpened}
+ {@render children()}
+ {/if}
+ </div>
+</div>
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 11b9feb..a9b0cc9 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,12 +1,28 @@
<script lang="ts">
import type { ModelMetadata } from "@dispatch/transport-contract";
- import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents";
+ import {
+ isNamedAgentSelection,
+ isVisionModel,
+ joinModelName,
+ modelKeyLabel,
+ modelsForKey,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ providerKeysWithNamedAgents,
+ splitModelName,
+ } from "../model-select";
let {
models,
selected,
onSelect,
modelInfo = {},
+ namedAgents = [],
+ namedAgentSnapshot = null,
+ onSelectNamedAgent,
+ zeroProviders = false,
}: {
models: readonly string[];
selected: string;
@@ -18,75 +34,199 @@
* Optional — absent metadata → no badge (treated as non-vision).
*/
modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */
+ namedAgents?: readonly NamedAgent[];
+ /** The active conversation's Named Agent snapshot (null when not selected via one). */
+ namedAgentSnapshot?: NamedAgentSelectionSnapshot | null;
+ /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */
+ onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void;
+ /**
+ * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO
+ * enabled providers (plan 06's `providers-status`) — renders a visible
+ * prompt above the picker instead of a silently empty one.
+ */
+ zeroProviders?: boolean;
} = $props();
- const keys = $derived(modelKeys(models));
+ // Whether the current selection is a Named Agent snapshot (the conversation was
+ // selected via one; its captured raw model equals `selected`).
+ const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot));
+
+ const providerKeys = $derived(providerKeysWithNamedAgents(models));
const current = $derived(splitModelName(selected));
const keyModels = $derived(modelsForKey(models, current.key));
+ // The provider shown in the provider dropdown: the synthetic Named Agents
+ // provider when the selection is a Named Agent snapshot, else the raw key.
+ const currentProvider = $derived(
+ currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER
+ ? NAMED_AGENTS_PROVIDER
+ : current.key,
+ );
+
+ const namedNames = $derived(namedAgentNames(namedAgents));
+
// Whether the currently-selected full model name is vision-capable.
const selectedVision = $derived(isVisionModel(modelInfo, selected));
- // Switching key jumps to the first model available under it.
- function selectKey(key: string): void {
+ // Whether the currently-selected raw model is PRESENT in the catalog (only
+ // meaningful for a raw selection — a Named Agent snapshot resolves to a raw
+ // model that may have left the catalog).
+ const rawMissing = $derived(
+ !currentIsNamedAgent && selected !== "" && !models.includes(selected),
+ );
+ const keyUnavailable = $derived(
+ !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key),
+ );
+
+ // Switching provider. Going TO the Named Agents provider: jump to the first
+ // Named Agent (mirrors selectKey jumping to the first model); the model
+ // dropdown then lists named names. Going to a raw provider: existing behavior.
+ function selectProvider(key: string): void {
+ if (key === currentProvider) return;
+ if (key === NAMED_AGENTS_PROVIDER) {
+ selectFirstNamedAgent();
+ return;
+ }
const first = modelsForKey(models, key)[0] ?? "";
onSelect(joinModelName(key, first));
}
- function selectModel(model: string): void {
+ // Selecting a raw model suffix under the current raw provider.
+ function selectRawModel(model: string): void {
onSelect(joinModelName(current.key, model));
}
- // The full `<key>/<model>` name for a model suffix under the current key.
+ // Selecting a Named Agent name (under the synthetic provider): resolve it to a
+ // snapshot + raw model and report via onSelectNamedAgent.
+ function selectNamedAgent(name: string): void {
+ const agent = namedAgents.find((a) => a.name === name);
+ if (agent === undefined) return;
+ onSelectNamedAgent?.({
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ });
+ }
+
+ // Jump to the first configured Named Agent (used when the provider is switched
+ // to Named Agents), matching the raw-provider "jump to first model" behavior.
+ function selectFirstNamedAgent(): void {
+ const first = namedAgents[0];
+ if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder
+ onSelectNamedAgent?.({
+ namedAgentId: first.id,
+ namedAgentName: first.name,
+ model: first.model,
+ });
+ }
+
+ // The full `<provider>/<model>` name for a raw model suffix under `current.key`.
function fullNameFor(modelSuffix: string): string {
return joinModelName(current.key, modelSuffix);
}
</script>
<div class="flex flex-col gap-2">
+ {#if zeroProviders}
+ <!-- First-boot guardrail (§7): a workspace with zero enabled providers
+ shows a visible prompt instead of a silently empty picker. -->
+ <div class="alert alert-warning text-sm" role="alert">
+ <span>
+ No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to
+ enable one.
+ </span>
+ </div>
+ {/if}
<select
class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
+ value={currentProvider}
+ onchange={(e) => selectProvider(e.currentTarget.value)}
aria-label="Key selector"
>
- {#each keys as key (key)}
- <option value={key}>{key}</option>
+ {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER}
+ <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option>
+ {/if}
+ {#each providerKeys as key (key)}
+ <option value={key}>
+ {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)}
+ </option>
{/each}
</select>
+
<select
class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
+ value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model}
+ onchange={
+ currentIsNamedAgent
+ ? (e) => selectNamedAgent(e.currentTarget.value)
+ : (e) => selectRawModel(e.currentTarget.value)
+ }
aria-label="Model selector"
+ disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0}
>
- {#each keyModels as model (model)}
- <option value={model}>
- {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
- </option>
- {/each}
+ {#if currentProvider === NAMED_AGENTS_PROVIDER}
+ {#if namedAgents.length === 0 && !currentIsNamedAgent}
+ <option value="" disabled>No named agents configured</option>
+ {:else}
+ {#each namedNames as name (name)}
+ <option value={name}>{namedAgentSelectionLabel(name)}</option>
+ {/each}
+ {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")}
+ <!-- The selected Named Agent was renamed/removed — keep showing the
+ captured snapshot name rather than silently switching (plan §7). -->
+ <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled>
+ {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable)
+ </option>
+ {/if}
+ {/if}
+ {:else}
+ {#each keyModels as model (model)}
+ <option value={model}>
+ {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+ </option>
+ {/each}
+ {#if rawMissing && !keyUnavailable}
+ <option value={current.model} disabled>{current.model} (unavailable)</option>
+ {/if}
+ {/if}
</select>
- {#if selectedVision}
- <div class="flex items-center gap-1 text-xs text-base-content/60">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-3.5 w-3.5"
- aria-hidden="true"
- >
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
- <circle cx="12" cy="12" r="3"></circle>
- </svg>
- <span>Vision — this model sees images natively</span>
+
+ {#if rawMissing}
+ <div class="text-xs text-warning" role="status">
+ Selected model “{selected}” is unavailable — choose another credential.
</div>
- {:else}
+ {/if}
+
+ {#if currentIsNamedAgent}
<div class="text-xs text-base-content/40">
- Pasted images are auto-described (vision handoff)
+ Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}.
</div>
{/if}
-</div>
+
+ {#if !currentIsNamedAgent}
+ {#if selectedVision}
+ <div class="flex items-center gap-1 text-xs text-base-content/60">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-3.5 w-3.5"
+ aria-hidden="true"
+ >
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+ <circle cx="12" cy="12" r="3"></circle>
+ </svg>
+ <span>Vision — this model sees images natively</span>
+ </div>
+ {:else}
+ <div class="text-xs text-base-content/40">
+ Pasted images are auto-described (vision handoff)
+ </div>
+ {/if}
+ {/if}
+</div> \ No newline at end of file
diff --git a/src/features/chat/ui/PendingInputWarning.svelte b/src/features/chat/ui/PendingInputWarning.svelte
new file mode 100644
index 0000000..c9e675e
--- /dev/null
+++ b/src/features/chat/ui/PendingInputWarning.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PendingInput } from "../pending-inputs";
+
+ let {
+ input,
+ onDiscard,
+ }: {
+ input: PendingInput;
+ onDiscard: (requestId: string) => void;
+ } = $props();
+
+ let visible = $state(false);
+
+ // Presentation only: each keyed row gets one grace period, including restored
+ // or rejected inputs. Prop updates never restart it. Confirmation/discard
+ // removes the row, cancelling the timer along with any other unmount.
+ onMount(() => {
+ const timer = setTimeout(() => {
+ visible = true;
+ }, 1000);
+ return () => clearTimeout(timer);
+ });
+</script>
+
+{#if visible}
+ <div class="m-3 rounded border border-warning p-3 text-sm" role="status">
+ <p>
+ {input.rejection
+ ? `Message rejected: ${input.rejection}`
+ : "Delivery unconfirmed. Your input is saved on this device."}
+ </p>
+ <p>Check the conversation history before sending again to avoid a duplicate.</p>
+ <textarea
+ class="textarea textarea-bordered mt-2 w-full"
+ aria-label="Saved message"
+ readonly
+ value={input.request.type === "chat.send" ? input.request.message : input.request.text}
+ ></textarea>
+ {#each input.request.images ?? [] as image}
+ <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" />
+ {/each}
+ <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)}
+ >Discard saved copy</button
+ >
+ </div>
+{/if}
diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte
new file mode 100644
index 0000000..dcb7aee
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import type { PendingInput } from "../pending-inputs";
+ import PendingInputWarning from "./PendingInputWarning.svelte";
+
+ let {
+ inputs,
+ onDiscard,
+ }: {
+ inputs: readonly PendingInput[];
+ onDiscard: (requestId: string) => void;
+ } = $props();
+</script>
+
+{#each inputs as input (input.request.requestId)}
+ <PendingInputWarning {input} {onDiscard} />
+{/each}
diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts
new file mode 100644
index 0000000..cea770d
--- /dev/null
+++ b/src/features/chat/ui/PendingInputs.test.ts
@@ -0,0 +1,217 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PendingInput } from "../pending-inputs";
+import { createChatStore } from "../store.svelte";
+import {
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
+} from "../test-helpers";
+import PendingInputs from "./PendingInputs.svelte";
+
+function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput {
+ return {
+ request: {
+ ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }),
+ conversationId: "c",
+ requestId,
+ images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }],
+ },
+ };
+}
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ cleanup();
+ const remaining = vi.getTimerCount();
+ vi.useRealTimers();
+ expect(remaining).toBe(0);
+});
+
+describe("pending input warning grace period", () => {
+ it.each([
+ "chat.send",
+ "chat.queue",
+ ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => {
+ const { container } = render(PendingInputs, {
+ inputs: [input("saved message", type)],
+ onDiscard: vi.fn(),
+ });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(999);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device.");
+ expect(warning).toHaveTextContent(
+ "Check the conversation history before sending again to avoid a duplicate.",
+ );
+ expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message");
+ expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute(
+ "src",
+ "data:image/png;base64,AAAA",
+ );
+ const content = warning.innerHTML;
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning.innerHTML).toBe(content);
+ });
+
+ it.each([
+ "confirmation",
+ "discard",
+ ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => {
+ const transport = createFakeTransport();
+ const save = vi.fn();
+ const store = createChatStore({
+ conversationId: "c",
+ createRequestId: () => "first",
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ pendingInputStorage: { load: () => [], save },
+ });
+ try {
+ store.send("first");
+ // Saving and transmission stay immediate, independent of warning display.
+ expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs);
+ expect(transport.sent).toHaveLength(1);
+ const { container, rerender } = render(PendingInputs, {
+ inputs: store.pendingInputs,
+ onDiscard: store.discardPendingInput,
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ if (resolution === "confirmation") {
+ store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" });
+ } else {
+ store.discardPendingInput("first");
+ }
+ expect(store.pendingInputs).toEqual([]);
+ expect(save).toHaveBeenLastCalledWith([]);
+ await rerender({ inputs: store.pendingInputs });
+ expect(vi.getTimerCount()).toBe(0);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("hides an already visible warning immediately on confirmation", async () => {
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("preserves the discard action and hides the saved copy without another delay", async () => {
+ const onDiscard = vi.fn();
+ const { container, rerender } = render(PendingInputs, {
+ inputs: [input("first")],
+ onDiscard,
+ });
+ await vi.advanceTimersByTimeAsync(1000);
+ await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" }));
+ expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first");
+ await rerender({ inputs: [] });
+ expect(container.children).toHaveLength(0);
+ expect(container.textContent).toBe("");
+ });
+
+ it("cancels all waiting timers on unmount", async () => {
+ const { unmount } = render(PendingInputs, {
+ inputs: [input("first"), input("second")],
+ onDiscard: vi.fn(),
+ });
+ expect(vi.getTimerCount()).toBe(2);
+ await vi.advanceTimersByTimeAsync(500);
+ unmount();
+ expect(vi.getTimerCount()).toBe(0);
+ await vi.advanceTimersByTimeAsync(10_000);
+ expect(screen.queryByRole("status")).toBeNull();
+ });
+
+ it("gives successive inputs independent delays without restarting existing rows", async () => {
+ const first = input("first");
+ const second = input("second");
+ const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(screen.getByRole("textbox")).toHaveValue("first");
+ await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(599);
+ expect(screen.getAllByRole("status")).toEqual([warning]);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(
+ screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value),
+ ).toEqual(["second", "first"]);
+ // Removing the older warning must not re-hide/restart the newer warning.
+ await rerender({ inputs: [second] });
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => {
+ const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [] });
+ await rerender({ inputs: [input("second")] });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("textbox")).toHaveValue("second");
+ });
+
+ it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => {
+ const pending = input("first");
+ const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() });
+ await vi.advanceTimersByTimeAsync(600);
+ await rerender({ inputs: [{ ...pending, rejection: "disk full" }] });
+ await vi.advanceTimersByTimeAsync(399);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ const warning = screen.getByRole("status");
+ expect(warning).toHaveTextContent("Message rejected: disk full");
+ expect(warning).not.toHaveTextContent("Delivery unconfirmed");
+ await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] });
+ expect(screen.getByRole("status")).toBe(warning);
+ expect(warning).toHaveTextContent("Message rejected: still rejected");
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it.each([
+ undefined,
+ "disk full",
+ ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => {
+ const restored = input("restored saved message");
+ render(PendingInputs, {
+ inputs: [rejection === undefined ? restored : { ...restored, rejection }],
+ onDiscard: vi.fn(),
+ });
+ await vi.advanceTimersByTimeAsync(999);
+ expect(screen.queryByRole("status")).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(screen.getByRole("status")).toHaveTextContent(
+ rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full",
+ );
+ expect(screen.getByRole("textbox")).toHaveValue("restored saved message");
+ expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled();
+ });
+});
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
index d982905..6858779 100644
--- a/src/features/chat/ui/ReasoningEffortSelector.svelte
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -1,34 +1,45 @@
<script lang="ts">
import type { ReasoningEffort } from "@dispatch/transport-contract";
import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
+ effectiveSelection,
+ isThinkingSelection,
+ selectionOptions,
+ type SaveThinkingSelection,
+ type ThinkingSelection,
} from "../reasoning-effort";
let {
- persisted,
+ persistedEffort,
+ persistedThinking,
save,
}: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
+ /** The conversation's persisted effort level, or null when never set (default applies). */
+ persistedEffort: ReasoningEffort | null;
+ /**
+ * The conversation's persisted thinking flag, or null when never set
+ * (thinking ON — the default). `false` ⇒ thinking disabled (the separate
+ * "off" axis); the effort level is preserved across an off→on toggle.
+ */
+ persistedThinking: boolean | null;
+ /** Persist a thinking selection (off or a level). */
+ save: SaveThinkingSelection;
} = $props();
- const options = effortOptions();
+ const options = selectionOptions();
- // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- let chosen = $state<ReasoningEffort | null>(null);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted
+ // selection. Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ThinkingSelection | null>(null);
let saving = $state(false);
let error = $state<string | null>(null);
let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(
+ chosen ?? effectiveSelection(persistedEffort, persistedThinking),
+ );
async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
+ if (!isThinkingSelection(value) || saving) return;
chosen = value;
saving = true;
error = null;
@@ -40,7 +51,7 @@
justSaved = true;
} else {
error = result.error;
- chosen = null; // revert to the persisted value
+ chosen = null; // revert to the persisted selection
}
}
</script>
@@ -69,7 +80,7 @@
<p class="text-xs text-success">Saved — applies from the next turn.</p>
{:else}
<p class="text-xs opacity-50">
- How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
+ How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once.
</p>
{/if}
</div>
diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte
new file mode 100644
index 0000000..10f8528
--- /dev/null
+++ b/src/features/chat/ui/ToolCard.svelte
@@ -0,0 +1,274 @@
+<script lang="ts">
+ import type { ToolEntry } from "../index";
+ import { viewToolEntry } from "../logic/tool-summary";
+ import LazyCollapse from "./LazyCollapse.svelte";
+ import { TOOL_ICON_COMPONENTS } from "./tool-icons";
+
+ let { entry }: { entry: ToolEntry } = $props();
+ const card = $derived(viewToolEntry(entry));
+ const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]);
+ let showAll = $state(false);
+</script>
+
+<div class="tool-card">
+ <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4">
+ {#snippet title()}
+ <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}>
+ {#if card.status === "pending"}
+ <!-- Same in-progress animation as the streaming thinking block. All three
+ status icons share a fixed w-5 slot so the row never shifts on settle. -->
+ <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span>
+ {:else if card.status === "error"}
+ <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span>
+ {:else}
+ <span
+ class="w-5 shrink-0 text-center text-success"
+ class:opacity-60={card.callDetail.kind === "unavailable"}
+ aria-label="Succeeded">✓</span
+ >
+ {/if}
+ <!-- Per-tool Phosphor icon (key chosen by the pure view-model). -->
+ <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" />
+ <span class="tool-name truncate" title={card.name}>{card.label}</span>
+ <span
+ class="tool-summary truncate font-normal text-base-content/60"
+ class:earlier-history={card.callDetail.kind === "unavailable"}
+ title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary}
+ >
+ {card.callSummary}
+ </span>
+ <span class="tool-badges gap-1 text-xs">
+ {#each card.callBadges as badge}
+ <span
+ class:text-success={badge.tone === "success"}
+ class:text-error={badge.tone === "error"}>{badge.label}</span
+ >
+ {/each}
+ </span>
+ {#if card.resultSummary !== null}
+ <span
+ class="tool-result text-xs"
+ class:text-error={card.status === "error"}
+ title={card.resultSummary}
+ >
+ {#if card.resultSummary === card.compactResultSummary}
+ <span>→ {card.resultSummary}</span>
+ {:else}
+ <span class="result-full">→ {card.resultSummary}</span>
+ <span class="result-compact">→ {card.compactResultSummary}</span>
+ {/if}
+ </span>
+ {:else}
+ <span class="tool-result text-xs text-base-content/60">→ running…</span>
+ {/if}
+ </div>
+ {/snippet}
+ <div class="min-w-0 space-y-4 text-xs">
+ <section aria-label="Call">
+ {#if card.callDetail.kind === "unavailable"}
+ <aside
+ role="note"
+ class="border-l-2 border-base-content/20 pl-3 italic text-base-content/60"
+ >
+ {card.callDetail.text}
+ </aside>
+ {:else}
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Call
+ </h3>
+ {/if}
+ {#if card.callDetail.kind === "none"}
+ <p class="italic text-base-content/60">No arguments</p>
+ {:else if card.callDetail.kind === "command"}
+ <pre class="overflow-x-auto">$ {card.callDetail.text}</pre>
+ {:else if card.callDetail.kind === "edit"}
+ <div class="mb-2 break-all font-mono">{card.callDetail.path}</div>
+ <!-- Unified-diff style: every removed/added line carries its own −/+ gutter
+ marker and a tinted row, so multi-line edits read as one diff block. -->
+ <div class="edit-diff overflow-x-auto rounded font-mono" role="group" aria-label="Edit diff">
+ {#if card.callDetail.oldText !== ""}
+ {#each card.callDetail.oldText.split("\n") as line, i (i)}
+ <div class="edit-diff-row edit-diff-del">
+ <span class="edit-diff-sign" aria-label="removed">−</span><span>{line}</span>
+ </div>
+ {/each}
+ {/if}
+ {#if card.callDetail.newText !== ""}
+ {#each card.callDetail.newText.split("\n") as line, i (i)}
+ <div class="edit-diff-row edit-diff-add">
+ <span class="edit-diff-sign" aria-label="added">+</span><span>{line}</span>
+ </div>
+ {/each}
+ {/if}
+ </div>
+ {:else if card.callDetail.kind === "fields"}
+ <dl class="call-fields">
+ {#each card.callDetail.fields as field}
+ <dt class="font-semibold text-base-content/85">{field.key}</dt>
+ <dd class="min-w-0">
+ <pre
+ class="overflow-x-auto"
+ class:whitespace-pre-wrap={!field.multiline}
+ class:break-words={!field.multiline}>{field.value}</pre>
+ </dd>
+ {/each}
+ </dl>
+ {/if}
+ </section>
+ <section aria-label="Response">
+ <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content">
+ Response
+ </h3>
+ {#if card.output === null}
+ <span class="text-base-content/75">running…</span>
+ {:else}
+ <div class:text-error={card.output.isError}>
+ {#if card.output.kind === "full"}
+ <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre>
+ {:else}
+ {#if !showAll}
+ <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre>
+ {/if}
+ <div class="output-controls" class:sticky-controls={showAll}>
+ <button
+ class="btn btn-soft btn-xs focus-visible:outline-primary"
+ aria-expanded={showAll}
+ onclick={() => {
+ showAll = !showAll;
+ }}
+ >
+ {#if showAll}<span aria-hidden="true">↑</span>{/if}
+ {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`}
+ </button>
+ </div>
+ {#if showAll}
+ <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre>
+ {:else}
+ <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre>
+ {/if}
+ {/if}
+ </div>
+ {/if}
+ </section>
+ </div>
+ </LazyCollapse>
+</div>
+
+<style>
+ .tool-card {
+ container-type: inline-size;
+ }
+ .tool-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ min-width: 0;
+ }
+ /* English names are short phrases: keep them whole and let the call
+ summary absorb the squeeze. The cap only bites on long MCP names. */
+ .tool-name {
+ flex: 0 0 auto;
+ max-width: 60%;
+ }
+ .tool-summary {
+ flex: 1;
+ min-width: 0;
+ }
+ .earlier-history {
+ font-style: italic;
+ color: var(--color-base-content);
+ opacity: 0.7;
+ }
+ .tool-badges {
+ display: flex;
+ flex-shrink: 0;
+ }
+ .tool-badges:empty {
+ display: none;
+ }
+ .tool-result {
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+ .result-compact {
+ display: none;
+ }
+ .call-fields {
+ display: grid;
+ grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr);
+ gap: 0.5rem 1rem;
+ align-items: baseline;
+ }
+ .call-fields dt {
+ max-width: 10rem;
+ overflow-wrap: anywhere;
+ }
+ .edit-diff {
+ background: var(--color-base-300);
+ padding-block: 0.25rem;
+ }
+ .edit-diff-row {
+ display: flex;
+ min-width: max-content;
+ white-space: pre;
+ padding-inline-end: 0.75rem;
+ }
+ .edit-diff-sign {
+ flex-shrink: 0;
+ width: 1.75rem;
+ text-align: center;
+ user-select: none;
+ }
+ .edit-diff-del {
+ color: var(--color-error);
+ background: color-mix(in oklab, var(--color-error) 12%, transparent);
+ }
+ .edit-diff-add {
+ color: var(--color-success);
+ background: color-mix(in oklab, var(--color-success) 12%, transparent);
+ }
+ .output-controls {
+ padding-block: 0.5rem;
+ color: var(--color-base-content);
+ /* Same fill as the LazyCollapse inset well this sits in (opaque, so
+ scrolled output never shows through the sticky bar). */
+ background: var(--inset-well-bg, var(--color-base-200));
+ }
+ .sticky-controls {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ margin-bottom: 0.5rem;
+ }
+ @container (max-width: 48rem) {
+ .result-full {
+ display: none;
+ }
+ .result-compact {
+ display: inline;
+ }
+ }
+ @container (max-width: 40rem) {
+ .tool-badges {
+ display: none;
+ }
+ .has-earlier-call .tool-result {
+ display: none;
+ }
+ }
+ @container (max-width: 24rem) {
+ .tool-header {
+ gap: 0.375rem;
+ }
+ .call-fields {
+ column-gap: 0.75rem;
+ }
+ }
+ @container (max-width: 18rem) {
+ .tool-result {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts
new file mode 100644
index 0000000..1d68ebe
--- /dev/null
+++ b/src/features/chat/ui/tool-icons.ts
@@ -0,0 +1,52 @@
+// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see
+// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the
+// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on
+// purpose: the package root re-exports ~1,500 components, and Svelte libraries
+// are not prebundled in dev, so a root import would load every one of them.
+
+import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon";
+import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon";
+import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon";
+import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon";
+import CpuIcon from "phosphor-svelte/lib/CpuIcon";
+import EyeIcon from "phosphor-svelte/lib/EyeIcon";
+import FileTextIcon from "phosphor-svelte/lib/FileTextIcon";
+import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon";
+import ImageIcon from "phosphor-svelte/lib/ImageIcon";
+import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon";
+import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon";
+import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon";
+import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon";
+import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon";
+import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon";
+import RobotIcon from "phosphor-svelte/lib/RobotIcon";
+import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon";
+import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon";
+import WrenchIcon from "phosphor-svelte/lib/WrenchIcon";
+import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon";
+import type { Component } from "svelte";
+import type { ToolIconKey } from "../logic/tool-summary";
+
+// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class.
+export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = {
+ terminal: TerminalWindowIcon,
+ "file-read": FileTextIcon,
+ "file-edit": PencilSimpleIcon,
+ "file-write": FloppyDiskIcon,
+ search: MagnifyingGlassIcon,
+ code: BracketsCurlyIcon,
+ todos: ListChecksIcon,
+ skill: BookOpenIcon,
+ vision: EyeIcon,
+ image: ImageIcon,
+ youtube: YoutubeLogoIcon,
+ "agent-summon": RobotIcon,
+ "agent-send": PaperPlaneTiltIcon,
+ "agent-read": ChatCircleTextIcon,
+ "agent-open": ArrowSquareOutIcon,
+ "agent-cancel": ProhibitIcon,
+ agents: UsersThreeIcon,
+ models: CpuIcon,
+ mcp: PuzzlePieceIcon,
+ tool: WrenchIcon,
+};