summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:21:55 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commitc333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch)
tree0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/chat
parent1285564f12238b22f6b39b9f3fbcecaca8456911 (diff)
downloaddispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz
dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/index.ts28
-rw-r--r--src/features/chat/model-select.test.ts88
-rw-r--r--src/features/chat/model-select.ts44
-rw-r--r--src/features/chat/ports.ts24
-rw-r--r--src/features/chat/reasoning-effort.test.ts68
-rw-r--r--src/features/chat/reasoning-effort.ts32
-rw-r--r--src/features/chat/store.svelte.ts724
-rw-r--r--src/features/chat/store.test.ts3060
-rw-r--r--src/features/chat/test-helpers.ts228
-rw-r--r--src/features/chat/ui.test.ts1526
-rw-r--r--src/features/chat/ui/ChatView.svelte611
-rw-r--r--src/features/chat/ui/CompactionView.svelte278
-rw-r--r--src/features/chat/ui/Composer.svelte358
-rw-r--r--src/features/chat/ui/ModelSelector.svelte82
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte130
15 files changed, 3667 insertions, 3614 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index c120916..ddb094d 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,23 +1,23 @@
export type {
- ProviderRetryView,
- RenderedChunk,
- RenderGroup,
- ToolBatchEntry,
+ ProviderRetryView,
+ RenderedChunk,
+ RenderGroup,
+ ToolBatchEntry,
} from "../../core/chunks";
export { groupRenderedChunks, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
- EffortOption,
- ReasoningEffortSaveResult,
- SaveReasoningEffort,
+ EffortOption,
+ ReasoningEffortSaveResult,
+ SaveReasoningEffort,
} from "./reasoning-effort";
export {
- DEFAULT_REASONING_EFFORT,
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- REASONING_EFFORT_LEVELS,
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ REASONING_EFFORT_LEVELS,
} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
@@ -30,6 +30,6 @@ export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "chat",
- description: "Conversation turns, composer, model selector, and metrics",
+ name: "chat",
+ description: "Conversation turns, composer, model selector, and metrics",
} as const;
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
index 109cae1..deb673d 100644
--- a/src/features/chat/model-select.test.ts
+++ b/src/features/chat/model-select.test.ts
@@ -2,57 +2,57 @@ import { describe, expect, it } from "vitest";
import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select";
describe("splitModelName", () => {
- it("splits on the first slash", () => {
- expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" });
- });
-
- it("keeps slashes in the model part (splits only the first)", () => {
- expect(splitModelName("openrouter/anthropic/claude")).toEqual({
- key: "openrouter",
- model: "anthropic/claude",
- });
- });
-
- it("treats a slashless name as all key", () => {
- expect(splitModelName("local")).toEqual({ key: "local", model: "" });
- });
+ it("splits on the first slash", () => {
+ expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" });
+ });
+
+ it("keeps slashes in the model part (splits only the first)", () => {
+ expect(splitModelName("openrouter/anthropic/claude")).toEqual({
+ key: "openrouter",
+ model: "anthropic/claude",
+ });
+ });
+
+ it("treats a slashless name as all key", () => {
+ expect(splitModelName("local")).toEqual({ key: "local", model: "" });
+ });
});
describe("joinModelName", () => {
- it("recombines key + model", () => {
- expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4");
- });
-
- it("returns just the key when the model is empty", () => {
- expect(joinModelName("local", "")).toBe("local");
- });
-
- it("round-trips with splitModelName", () => {
- const full = "openrouter/anthropic/claude";
- const { key, model } = splitModelName(full);
- expect(joinModelName(key, model)).toBe(full);
- });
+ it("recombines key + model", () => {
+ expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4");
+ });
+
+ it("returns just the key when the model is empty", () => {
+ expect(joinModelName("local", "")).toBe("local");
+ });
+
+ it("round-trips with splitModelName", () => {
+ const full = "openrouter/anthropic/claude";
+ const { key, model } = splitModelName(full);
+ expect(joinModelName(key, model)).toBe(full);
+ });
});
describe("modelKeys", () => {
- it("returns distinct keys in first-seen order", () => {
- expect(
- modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]),
- ).toEqual(["openai", "anthropic", "google"]);
- });
-
- it("is empty for no models", () => {
- expect(modelKeys([])).toEqual([]);
- });
+ it("returns distinct keys in first-seen order", () => {
+ expect(
+ modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]),
+ ).toEqual(["openai", "anthropic", "google"]);
+ });
+
+ it("is empty for no models", () => {
+ expect(modelKeys([])).toEqual([]);
+ });
});
describe("modelsForKey", () => {
- it("returns the model suffixes under a key, in order", () => {
- const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
- expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]);
- });
-
- it("returns empty for an unknown key", () => {
- expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]);
- });
+ it("returns the model suffixes under a key, in order", () => {
+ const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
+ expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]);
+ });
+
+ it("returns empty for an unknown key", () => {
+ expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]);
+ });
});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index b1d70b9..db41772 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -8,42 +8,42 @@
*/
export interface SplitModel {
- readonly key: string;
- readonly model: string;
+ readonly key: string;
+ readonly model: string;
}
/** Split `<key>/<model>` on the first slash. A slashless name is all-key. */
export function splitModelName(full: string): SplitModel {
- const i = full.indexOf("/");
- if (i === -1) return { key: full, model: "" };
- return { key: full.slice(0, i), model: full.slice(i + 1) };
+ const i = full.indexOf("/");
+ if (i === -1) return { key: full, model: "" };
+ return { key: full.slice(0, i), model: full.slice(i + 1) };
}
/** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */
export function joinModelName(key: string, model: string): string {
- return model === "" ? key : `${key}/${model}`;
+ return model === "" ? key : `${key}/${model}`;
}
/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
- const seen = new Set<string>();
- const out: string[] = [];
- for (const full of models) {
- const { key } = splitModelName(full);
- if (!seen.has(key)) {
- seen.add(key);
- out.push(key);
- }
- }
- return out;
+ const seen = new Set<string>();
+ const out: string[] = [];
+ for (const full of models) {
+ const { key } = splitModelName(full);
+ if (!seen.has(key)) {
+ seen.add(key);
+ out.push(key);
+ }
+ }
+ return out;
}
/** The model suffixes available under a given key, in order. */
export function modelsForKey(models: readonly string[], key: string): string[] {
- const out: string[] = [];
- for (const full of models) {
- const split = splitModelName(full);
- if (split.key === key) out.push(split.model);
- }
- return out;
+ const out: string[] = [];
+ for (const full of models) {
+ const split = splitModelName(full);
+ if (split.key === key) out.push(split.model);
+ }
+ return out;
}
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index ffe2c94..2fe10dc 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,8 +1,8 @@
import type {
- ChatQueueMessage,
- ChatSendMessage,
- ConversationHistoryResponse,
- ConversationMetricsResponse,
+ ChatQueueMessage,
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
} from "@dispatch/transport-contract";
/**
@@ -11,7 +11,7 @@ import type {
* auto-starts a turn if idle).
*/
export interface ChatTransport {
- send(msg: ChatSendMessage | ChatQueueMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage): void;
}
/**
@@ -19,10 +19,10 @@ export interface ChatTransport {
* Both must be POSITIVE integers when present (the server 400s otherwise).
*/
export interface HistoryWindow {
- /** Return only the NEWEST `limit` chunks of the selection (still ascending). */
- readonly limit?: number;
- /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */
- readonly beforeSeq?: number;
+ /** Return only the NEWEST `limit` chunks of the selection (still ascending). */
+ readonly limit?: number;
+ /** Exclusive upper bound: only chunks with `seq < beforeSeq` (backfill paging). */
+ readonly beforeSeq?: number;
}
/**
@@ -34,9 +34,9 @@ export interface HistoryWindow {
* satisfies this naturally).
*/
export type HistorySync = (
- conversationId: string,
- sinceSeq: number,
- window?: HistoryWindow,
+ conversationId: string,
+ sinceSeq: number,
+ window?: HistoryWindow,
) => Promise<ConversationHistoryResponse>;
/** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
index 8f76dea..6d409e9 100644
--- a/src/features/chat/reasoning-effort.test.ts
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -1,45 +1,45 @@
import { describe, expect, it } from "vitest";
import {
- DEFAULT_REASONING_EFFORT,
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- REASONING_EFFORT_LEVELS,
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ REASONING_EFFORT_LEVELS,
} from "./reasoning-effort";
describe("reasoning-effort helpers", () => {
- it("ladder matches the wire contract, in ascending depth order", () => {
- expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]);
- });
+ it("ladder matches the wire contract, in ascending depth order", () => {
+ expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ });
- it("the server default is high", () => {
- expect(DEFAULT_REASONING_EFFORT).toBe("high");
- });
+ it("the server default is high", () => {
+ expect(DEFAULT_REASONING_EFFORT).toBe("high");
+ });
- it("isReasoningEffort narrows ladder strings and rejects everything else", () => {
- for (const level of REASONING_EFFORT_LEVELS) {
- expect(isReasoningEffort(level)).toBe(true);
- }
- expect(isReasoningEffort("banana")).toBe(false);
- expect(isReasoningEffort("")).toBe(false);
- expect(isReasoningEffort("HIGH")).toBe(false);
- });
+ it("isReasoningEffort narrows ladder strings and rejects everything else", () => {
+ for (const level of REASONING_EFFORT_LEVELS) {
+ expect(isReasoningEffort(level)).toBe(true);
+ }
+ expect(isReasoningEffort("banana")).toBe(false);
+ expect(isReasoningEffort("")).toBe(false);
+ expect(isReasoningEffort("HIGH")).toBe(false);
+ });
- it("effectiveEffort maps null (never set) to the default, not 'off'", () => {
- expect(effectiveEffort(null)).toBe("high");
- });
+ it("effectiveEffort maps null (never set) to the default, not 'off'", () => {
+ expect(effectiveEffort(null)).toBe("high");
+ });
- it("effectiveEffort passes a persisted value through", () => {
- expect(effectiveEffort("xhigh")).toBe("xhigh");
- expect(effectiveEffort("low")).toBe("low");
- });
+ it("effectiveEffort passes a persisted value through", () => {
+ expect(effectiveEffort("xhigh")).toBe("xhigh");
+ expect(effectiveEffort("low")).toBe("low");
+ });
- it("effortOptions lists every level once and marks only the default", () => {
- const options = effortOptions();
- expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
- expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
- for (const option of options) {
- if (option.value !== "high") expect(option.label).toBe(option.value);
- }
- });
+ it("effortOptions lists every level once and marks only the default", () => {
+ const options = effortOptions();
+ expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
+ expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
+ for (const option of options) {
+ if (option.value !== "high") expect(option.label).toBe(option.value);
+ }
+ });
});
diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts
index 2a55089..1eb77b6 100644
--- a/src/features/chat/reasoning-effort.ts
+++ b/src/features/chat/reasoning-effort.ts
@@ -13,11 +13,11 @@ import type { ReasoningEffort } from "@dispatch/transport-contract";
/** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */
export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [
- "low",
- "medium",
- "high",
- "xhigh",
- "max",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
];
/** The server's fallback when nothing is set (the resolution chain's tail). */
@@ -25,7 +25,7 @@ export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high";
/** Narrow an untrusted string (e.g. a `<select>` value) to the ladder. */
export function isReasoningEffort(value: string): value is ReasoningEffort {
- return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value);
+ return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value);
}
/**
@@ -33,13 +33,13 @@ export function isReasoningEffort(value: string): value is ReasoningEffort {
* server default when never set (`null` = "default applies", not "off").
*/
export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort {
- return persisted ?? DEFAULT_REASONING_EFFORT;
+ return persisted ?? DEFAULT_REASONING_EFFORT;
}
/** One `<option>` of the selector. */
export interface EffortOption {
- readonly value: ReasoningEffort;
- readonly label: string;
+ readonly value: ReasoningEffort;
+ readonly label: string;
}
/**
@@ -47,10 +47,10 @@ export interface EffortOption {
* `(default)` so a never-set conversation reads "high (default)".
*/
export function effortOptions(): readonly EffortOption[] {
- return REASONING_EFFORT_LEVELS.map((level) => ({
- value: level,
- label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level,
- }));
+ return REASONING_EFFORT_LEVELS.map((level) => ({
+ value: level,
+ label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level,
+ }));
}
// ── Injected port (consumer-defines-port; the composition root adapts the
@@ -58,9 +58,9 @@ export function effortOptions(): readonly EffortOption[] {
/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
export type ReasoningEffortSaveResult =
- | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
export type SaveReasoningEffort = (
- level: ReasoningEffort,
+ level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index a31fe55..3588da1 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,398 +1,398 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ChatQueueMessage,
- ChatSendMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
} from "@dispatch/transport-contract";
import type { ChatMessage, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
import {
- appendUserMessage,
- applyHistory,
- clearGenerating,
- foldEvent,
- initialState,
- initialWindowSize,
- normalizeChatLimit,
- restoreEarlier,
- selectChunks,
- selectGenerating,
- selectHasEarlier,
- selectMessages,
- selectProviderRetry,
- trimTranscript,
- unloadCount,
- windowTranscript,
+ appendUserMessage,
+ applyHistory,
+ clearGenerating,
+ foldEvent,
+ initialState,
+ initialWindowSize,
+ normalizeChatLimit,
+ restoreEarlier,
+ selectChunks,
+ selectGenerating,
+ selectHasEarlier,
+ selectMessages,
+ selectProviderRetry,
+ trimTranscript,
+ unloadCount,
+ windowTranscript,
} from "../../core/chunks";
import type { MetricsState, TurnMetricsEntry } from "../../core/metrics";
import {
- applyDurableMetrics,
- foldMetricsEvent,
- initialMetricsState,
- selectCurrentContextSize,
- selectOrderedTurnMetrics,
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ selectCurrentContextSize,
+ selectOrderedTurnMetrics,
} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
- readonly conversationId: string;
- readonly model?: string;
- readonly transport: ChatTransport;
- readonly historySync: HistorySync;
- readonly metricsSync: MetricsSync;
- readonly cache: ConversationCache;
- /**
- * The workspace this conversation belongs to (its URL slug). Sent on
- * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
- * at creation (default "default"). Absent → omitted (legacy behavior).
- */
- readonly workspaceId?: string;
- /**
- * The chat limit: max loaded chunks before the oldest quarter is unloaded
- * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
- * `DEFAULT_CHAT_LIMIT`.
- */
- readonly chatLimit?: number;
- /**
- * Whether unloading may run RIGHT NOW. The composition root wires this to the
- * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a
- * trim would yank the content under them, so it is DEFERRED until they return
- * to the bottom (the next fold retries). Absent → always allowed.
- */
- readonly canUnload?: () => boolean;
- /**
- * Called when a swallowed error should be surfaced to the user (e.g. a
- * metrics sync failure). Wired by the composition root to the app store's
- * `reportError` → full-screen error modal. Absent → errors are logged only.
- */
- readonly onError?: (context: string, err: unknown) => void;
+ readonly conversationId: string;
+ readonly model?: string;
+ readonly transport: ChatTransport;
+ readonly historySync: HistorySync;
+ readonly metricsSync: MetricsSync;
+ readonly cache: ConversationCache;
+ /**
+ * The workspace this conversation belongs to (its URL slug). Sent on
+ * `chat.send`/`chat.queue` so the backend stamps the conversation's workspace
+ * at creation (default "default"). Absent → omitted (legacy behavior).
+ */
+ readonly workspaceId?: string;
+ /**
+ * The chat limit: max loaded chunks before the oldest quarter is unloaded
+ * (see `core/chunks/trim.ts`). Normalized via `normalizeChatLimit`; absent →
+ * `DEFAULT_CHAT_LIMIT`.
+ */
+ readonly chatLimit?: number;
+ /**
+ * Whether unloading may run RIGHT NOW. The composition root wires this to the
+ * smart-scroll "stuck to bottom" state: while the reader is scrolled up, a
+ * trim would yank the content under them, so it is DEFERRED until they return
+ * to the bottom (the next fold retries). Absent → always allowed.
+ */
+ readonly canUnload?: () => boolean;
+ /**
+ * Called when a swallowed error should be surfaced to the user (e.g. a
+ * metrics sync failure). Wired by the composition root to the app store's
+ * `reportError` → full-screen error modal. Absent → errors are logged only.
+ */
+ readonly onError?: (context: string, err: unknown) => void;
}
export interface ChatStore {
- readonly messages: readonly ChatMessage[];
- readonly chunks: readonly RenderedChunk[];
- readonly turnMetrics: readonly TurnMetricsEntry[];
- /**
- * The conversation's current context size (tokens occupied) — the latest
- * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
- * known yet. Never `0` for the unknown case.
- */
- readonly currentContextSize: number | undefined;
- /**
- * Whether a turn is currently generating server-side — derived from the event
- * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching
- * client: the sender, a second device, or a reconnected client whose in-flight
- * turn was replayed. Drives the composer's "generating…" indicator.
- */
- readonly generating: boolean;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when no
- * retry is pending. Drives the transient yellow "retrying…" warning banner —
- * never persisted (never sent to the model or replayed on reload). Coalesces
- * to the newest attempt + delay; cleared when content resumes or the turn ends.
- */
- readonly providerRetry: TurnProviderRetryEvent | null;
- readonly pendingSync: boolean;
- readonly error: string | null;
- readonly model: string | undefined;
- /**
- * Whether earlier history was unloaded by the chat limit (or never loaded by
- * the fresh-load window) and can be paged back in — drives the
- * "Show earlier messages" affordance.
- */
- readonly hasEarlier: boolean;
- /**
- * Render-key base for thinking collapses: how many thinking chunks are
- * unloaded below the watermark, so the UI's ordinal keys stay stable across
- * a trim (see `TranscriptState.hiddenThinkingCount`).
- */
- readonly thinkingKeyBase: number;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
- send(text: string): void;
- /**
- * Enqueue a steering message onto the conversation's queue (`chat.queue`
- * WS op). While a turn is generating, the message is delivered mid-turn at
- * the next tool-result boundary (a `steering` `AgentEvent` fires + the
- * message-queue surface updates). When no turn is active, the server
- * auto-starts a turn with the message as its opening prompt (equivalent to
- * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
- * pending message until drain; the `steering` event places it in the
- * transcript. `text` must be non-empty (the server 400/errors otherwise).
- */
- queueMessage(text: string): void;
- setModel(model: string): void;
- /**
- * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
- * Lowering it unloads older committed chunks (deferred via the gate while the
- * reader is scrolled up, catching up on the next mutation). Raising it
- * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the
- * fresh-load window (`initialWindowSize` = 75% of the limit) — the same
- * window a fresh `load()` would show — so upping the limit reveals more
- * history instead of leaving a partial view. New deltas + loads use the new
- * limit. The refill awaits, so a caller can preserve scroll over the prepend.
- */
- setChatLimit(limit: number): Promise<void>;
- load(): Promise<void>;
- /**
- * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the
- * "Show earlier messages" action. Local cache first; when the cache doesn't
- * reach far enough back (a server-windowed fresh load), the missing older
- * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache.
- */
- showEarlier(): Promise<void>;
- /**
- * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may
- * have sealed while disconnected — the live `turn-sealed` was missed), then
- * pulls newly-sealed turns from history (+ metrics). If the turn is still
- * running, the server's post-subscribe replay re-asserts `generating`. The
- * app store pairs this with a `chat.subscribe` for the conversation.
- */
- resync(): void;
- dispose(): void;
+ readonly messages: readonly ChatMessage[];
+ readonly chunks: readonly RenderedChunk[];
+ readonly turnMetrics: readonly TurnMetricsEntry[];
+ /**
+ * The conversation's current context size (tokens occupied) — the latest
+ * finalized turn's `contextSize`, or `undefined` ("unknown") when none is
+ * known yet. Never `0` for the unknown case.
+ */
+ readonly currentContextSize: number | undefined;
+ /**
+ * Whether a turn is currently generating server-side — derived from the event
+ * stream (`turn-start`…no-`done`/`turn-sealed`-yet). True for ANY watching
+ * client: the sender, a second device, or a reconnected client whose in-flight
+ * turn was replayed. Drives the composer's "generating…" indicator.
+ */
+ readonly generating: boolean;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when no
+ * retry is pending. Drives the transient yellow "retrying…" warning banner —
+ * never persisted (never sent to the model or replayed on reload). Coalesces
+ * to the newest attempt + delay; cleared when content resumes or the turn ends.
+ */
+ readonly providerRetry: TurnProviderRetryEvent | null;
+ readonly pendingSync: boolean;
+ readonly error: string | null;
+ readonly model: string | undefined;
+ /**
+ * Whether earlier history was unloaded by the chat limit (or never loaded by
+ * the fresh-load window) and can be paged back in — drives the
+ * "Show earlier messages" affordance.
+ */
+ readonly hasEarlier: boolean;
+ /**
+ * Render-key base for thinking collapses: how many thinking chunks are
+ * unloaded below the watermark, so the UI's ordinal keys stay stable across
+ * a trim (see `TranscriptState.hiddenThinkingCount`).
+ */
+ readonly thinkingKeyBase: number;
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
+ send(text: string): void;
+ /**
+ * Enqueue a steering message onto the conversation's queue (`chat.queue`
+ * WS op). While a turn is generating, the message is delivered mid-turn at
+ * the next tool-result boundary (a `steering` `AgentEvent` fires + the
+ * message-queue surface updates). When no turn is active, the server
+ * auto-starts a turn with the message as its opening prompt (equivalent to
+ * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the
+ * pending message until drain; the `steering` event places it in the
+ * transcript. `text` must be non-empty (the server 400/errors otherwise).
+ */
+ queueMessage(text: string): void;
+ setModel(model: string): void;
+ /**
+ * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
+ * Lowering it unloads older committed chunks (deferred via the gate while the
+ * reader is scrolled up, catching up on the next mutation). Raising it
+ * REFILLS older history (cache first, then CR-5 `?beforeSeq=`) up to the
+ * fresh-load window (`initialWindowSize` = 75% of the limit) — the same
+ * window a fresh `load()` would show — so upping the limit reveals more
+ * history instead of leaving a partial view. New deltas + loads use the new
+ * limit. The refill awaits, so a caller can preserve scroll over the prepend.
+ */
+ setChatLimit(limit: number): Promise<void>;
+ load(): Promise<void>;
+ /**
+ * Page one unload-unit (`ceil(limit/4)`) of earlier history back in — the
+ * "Show earlier messages" action. Local cache first; when the cache doesn't
+ * reach far enough back (a server-windowed fresh load), the missing older
+ * run is fetched via CR-5 `?beforeSeq=&limit=` and persisted to the cache.
+ */
+ showEarlier(): Promise<void>;
+ /**
+ * Re-sync after a WS (re)connect. Clears any stale `generating` (a turn may
+ * have sealed while disconnected — the live `turn-sealed` was missed), then
+ * pulls newly-sealed turns from history (+ metrics). If the turn is still
+ * running, the server's post-subscribe replay re-asserts `generating`. The
+ * app store pairs this with a `chat.subscribe` for the conversation.
+ */
+ resync(): void;
+ dispose(): void;
}
export function createChatStore(deps: ChatStoreDependencies): ChatStore {
- let transcript = $state<TranscriptState>(initialState());
- let metrics = $state<MetricsState>(initialMetricsState());
- let _pendingSync = $state(false);
- let _error = $state<string | null>(null);
- let _model = $state<string | undefined>(deps.model);
- let disposed = false;
+ let transcript = $state<TranscriptState>(initialState());
+ let metrics = $state<MetricsState>(initialMetricsState());
+ let _pendingSync = $state(false);
+ let _error = $state<string | null>(null);
+ let _model = $state<string | undefined>(deps.model);
+ let disposed = false;
- let chatLimit = normalizeChatLimit(deps.chatLimit);
+ let chatLimit = normalizeChatLimit(deps.chatLimit);
- /**
- * Enforce the chat limit after a transcript mutation — unless the injected
- * gate says the reader is scrolled up (then defer; the next mutation retries
- * and `trimTranscript` unloads whole quarters to catch up).
- */
- function maybeTrim(): void {
- if (deps.canUnload !== undefined && !deps.canUnload()) return;
- transcript = trimTranscript(transcript, chatLimit);
- }
+ /**
+ * Enforce the chat limit after a transcript mutation — unless the injected
+ * gate says the reader is scrolled up (then defer; the next mutation retries
+ * and `trimTranscript` unloads whole quarters to catch up).
+ */
+ function maybeTrim(): void {
+ if (deps.canUnload !== undefined && !deps.canUnload()) return;
+ transcript = trimTranscript(transcript, chatLimit);
+ }
- /**
- * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when
- * given AND the cache is empty (a truly fresh browser), windows the fetch to
- * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship
- * whole. It is deliberately NOT applied to a warm-cache tail: windowing a
- * tail that grew past N while we were away would leave a silent seq GAP
- * between the cache and the fetched window.
- */
- async function syncTail(coldLimit?: number): Promise<void> {
- if (disposed || _pendingSync) return;
- _pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
- const res = await deps.historySync(deps.conversationId, since, window);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- maybeTrim();
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
- }
- }
+ /**
+ * Pull `seq > cache-cursor` from the server and fold it in. `coldLimit`, when
+ * given AND the cache is empty (a truly fresh browser), windows the fetch to
+ * the newest N chunks (CR-5 `?limit=`) so a huge conversation doesn't ship
+ * whole. It is deliberately NOT applied to a warm-cache tail: windowing a
+ * tail that grew past N while we were away would leave a silent seq GAP
+ * between the cache and the fetched window.
+ */
+ async function syncTail(coldLimit?: number): Promise<void> {
+ if (disposed || _pendingSync) return;
+ _pendingSync = true;
+ try {
+ const since = await deps.cache.sinceSeq(deps.conversationId);
+ const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined;
+ const res = await deps.historySync(deps.conversationId, since, window);
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ transcript = applyHistory(transcript, merged);
+ maybeTrim();
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ } finally {
+ _pendingSync = false;
+ }
+ }
- async function syncMetrics(): Promise<void> {
- if (disposed) return;
- try {
- const res = await deps.metricsSync(deps.conversationId);
- metrics = applyDurableMetrics(metrics, res.turns);
- } catch (err) {
- // Metrics fetch failure must not block history sync or throw;
- // live-folded metrics remain intact. Surface via onError (modal).
- console.error("[syncMetrics] failed:", err);
- deps.onError?.("Failed to sync conversation metrics", err);
- }
- }
+ async function syncMetrics(): Promise<void> {
+ if (disposed) return;
+ try {
+ const res = await deps.metricsSync(deps.conversationId);
+ metrics = applyDurableMetrics(metrics, res.turns);
+ } catch (err) {
+ // Metrics fetch failure must not block history sync or throw;
+ // live-folded metrics remain intact. Surface via onError (modal).
+ console.error("[syncMetrics] failed:", err);
+ deps.onError?.("Failed to sync conversation metrics", err);
+ }
+ }
- /**
- * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a
- * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow,
- * persisting it so the next read is local. Returns every locally-known
- * chunk older than `oldest` (the caller — `restoreEarlier` — takes the
- * newest `count` of them). Shared by `showEarlier` and the raise-refill.
- */
- async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
- let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
- const oldestKnown = earlier[0]?.seq ?? oldest;
- if (earlier.length < want && oldestKnown > 1) {
- const res = await deps.historySync(deps.conversationId, 0, {
- beforeSeq: oldestKnown,
- limit: want - earlier.length,
- });
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- earlier = merged.filter((c) => c.seq < oldest);
- }
- return earlier;
- }
+ /**
+ * Fetch up to `want` older chunks (seq < `oldest`) — cache first, then a
+ * CR-5 `?beforeSeq=&limit=` server backfill when the cache is too shallow,
+ * persisting it so the next read is local. Returns every locally-known
+ * chunk older than `oldest` (the caller — `restoreEarlier` — takes the
+ * newest `count` of them). Shared by `showEarlier` and the raise-refill.
+ */
+ async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> {
+ let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest);
+ const oldestKnown = earlier[0]?.seq ?? oldest;
+ if (earlier.length < want && oldestKnown > 1) {
+ const res = await deps.historySync(deps.conversationId, 0, {
+ beforeSeq: oldestKnown,
+ limit: want - earlier.length,
+ });
+ const merged = await deps.cache.commit(deps.conversationId, res.chunks);
+ earlier = merged.filter((c) => c.seq < oldest);
+ }
+ return earlier;
+ }
- /**
- * Refill toward the fresh-load window after a limit RAISE: pull older
- * history (cache first, then server) so the loaded set grows to match what a
- * fresh `load()` would show at the new limit. No-op when already at the
- * origin (seq 1) or already within the window. `restoreEarlier` re-derives
- * the window start at apply time, so a delta landing during the await can't
- * corrupt the merge. NOT gated (refilling prepends above the viewport; the
- * caller preserves scroll position).
- */
- async function refill(): Promise<void> {
- if (disposed) return;
- const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
- const want = initialWindowSize(chatLimit) - transcript.committed.length;
- if (want <= 0) return;
- try {
- const earlier = await backfillOlder(oldest, want);
- if (earlier.length === 0) return;
- transcript = restoreEarlier(transcript, earlier, want);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- }
- }
+ /**
+ * Refill toward the fresh-load window after a limit RAISE: pull older
+ * history (cache first, then server) so the loaded set grows to match what a
+ * fresh `load()` would show at the new limit. No-op when already at the
+ * origin (seq 1) or already within the window. `restoreEarlier` re-derives
+ * the window start at apply time, so a delta landing during the await can't
+ * corrupt the merge. NOT gated (refilling prepends above the viewport; the
+ * caller preserves scroll position).
+ */
+ async function refill(): Promise<void> {
+ if (disposed) return;
+ const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
+ if (oldest <= 1) return;
+ const want = initialWindowSize(chatLimit) - transcript.committed.length;
+ if (want <= 0) return;
+ try {
+ const earlier = await backfillOlder(oldest, want);
+ if (earlier.length === 0) return;
+ transcript = restoreEarlier(transcript, earlier, want);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ }
- return {
- get messages(): readonly ChatMessage[] {
- return selectMessages(transcript);
- },
- get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
- },
- get turnMetrics(): readonly TurnMetricsEntry[] {
- return selectOrderedTurnMetrics(metrics);
- },
- get currentContextSize(): number | undefined {
- return selectCurrentContextSize(metrics);
- },
- get generating(): boolean {
- return selectGenerating(transcript);
- },
- get providerRetry(): TurnProviderRetryEvent | null {
- return selectProviderRetry(transcript);
- },
- get pendingSync(): boolean {
- return _pendingSync;
- },
- get error(): string | null {
- return _error;
- },
- get model(): string | undefined {
- return _model;
- },
- get hasEarlier(): boolean {
- return selectHasEarlier(transcript);
- },
- get thinkingKeyBase(): number {
- return transcript.hiddenThinkingCount;
- },
+ return {
+ get messages(): readonly ChatMessage[] {
+ return selectMessages(transcript);
+ },
+ get chunks(): readonly RenderedChunk[] {
+ return selectChunks(transcript);
+ },
+ get turnMetrics(): readonly TurnMetricsEntry[] {
+ return selectOrderedTurnMetrics(metrics);
+ },
+ get currentContextSize(): number | undefined {
+ return selectCurrentContextSize(metrics);
+ },
+ get generating(): boolean {
+ return selectGenerating(transcript);
+ },
+ get providerRetry(): TurnProviderRetryEvent | null {
+ return selectProviderRetry(transcript);
+ },
+ get pendingSync(): boolean {
+ return _pendingSync;
+ },
+ get error(): string | null {
+ return _error;
+ },
+ get model(): string | undefined {
+ return _model;
+ },
+ get hasEarlier(): boolean {
+ return selectHasEarlier(transcript);
+ },
+ get thinkingKeyBase(): number {
+ return transcript.hiddenThinkingCount;
+ },
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
- if (msg.type === "chat.error") {
- if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
- return;
- }
- _error = msg.message;
- return;
- }
- if (msg.event.conversationId !== deps.conversationId) {
- return;
- }
- transcript = foldEvent(transcript, msg.event);
- metrics = foldMetricsEvent(metrics, msg.event);
- maybeTrim();
- if (transcript.sealedTurnId !== null) {
- void syncTail();
- void syncMetrics();
- }
- },
+ handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void {
+ if (msg.type === "chat.error") {
+ if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) {
+ return;
+ }
+ _error = msg.message;
+ return;
+ }
+ if (msg.event.conversationId !== deps.conversationId) {
+ return;
+ }
+ transcript = foldEvent(transcript, msg.event);
+ metrics = foldMetricsEvent(metrics, msg.event);
+ maybeTrim();
+ if (transcript.sealedTurnId !== null) {
+ void syncTail();
+ void syncMetrics();
+ }
+ },
- send(text: string): void {
- transcript = appendUserMessage(transcript, text);
- maybeTrim();
- const msg: ChatSendMessage = {
- type: "chat.send",
- conversationId: deps.conversationId,
- message: text,
- ...(_model !== undefined ? { model: _model } : {}),
- ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
- };
- deps.transport.send(msg);
- },
+ send(text: string): void {
+ transcript = appendUserMessage(transcript, text);
+ maybeTrim();
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: deps.conversationId,
+ message: text,
+ ...(_model !== undefined ? { model: _model } : {}),
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ };
+ deps.transport.send(msg);
+ },
- queueMessage(text: string): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- const msg: ChatQueueMessage = {
- type: "chat.queue",
- conversationId: deps.conversationId,
- text: trimmed,
- ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
- };
- deps.transport.send(msg);
- },
+ queueMessage(text: string): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ const msg: ChatQueueMessage = {
+ type: "chat.queue",
+ conversationId: deps.conversationId,
+ text: trimmed,
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ };
+ deps.transport.send(msg);
+ },
- setModel(model: string): void {
- _model = model;
- },
+ setModel(model: string): void {
+ _model = model;
+ },
- async setChatLimit(limit: number): Promise<void> {
- const prev = chatLimit;
- chatLimit = normalizeChatLimit(limit);
- if (chatLimit < prev) {
- maybeTrim();
- } else if (chatLimit > prev) {
- await refill();
- }
- },
+ async setChatLimit(limit: number): Promise<void> {
+ const prev = chatLimit;
+ chatLimit = normalizeChatLimit(limit);
+ if (chatLimit < prev) {
+ maybeTrim();
+ } else if (chatLimit > prev) {
+ await refill();
+ }
+ },
- async load(): Promise<void> {
- // Fresh load shows only the newest 75% of the limit — headroom before the
- // first trim. A warm cache is windowed locally (synchronously with its
- // apply — no render in between); a COLD cache passes the window to the
- // server instead (CR-5 `?limit=`), so a huge conversation never ships
- // whole. The post-sync window re-asserts the cap either way.
- const windowSize = initialWindowSize(chatLimit);
- const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
- transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
- }
- await syncTail(windowSize);
- transcript = windowTranscript(transcript, windowSize);
- await syncMetrics();
- },
+ async load(): Promise<void> {
+ // Fresh load shows only the newest 75% of the limit — headroom before the
+ // first trim. A warm cache is windowed locally (synchronously with its
+ // apply — no render in between); a COLD cache passes the window to the
+ // server instead (CR-5 `?limit=`), so a huge conversation never ships
+ // whole. The post-sync window re-asserts the cap either way.
+ const windowSize = initialWindowSize(chatLimit);
+ const cached = await deps.cache.load(deps.conversationId);
+ if (cached.length > 0) {
+ transcript = windowTranscript(applyHistory(transcript, cached), windowSize);
+ }
+ await syncTail(windowSize);
+ transcript = windowTranscript(transcript, windowSize);
+ await syncMetrics();
+ },
- async showEarlier(): Promise<void> {
- if (disposed) return;
- const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
- if (oldest <= 1) return;
- const want = unloadCount(chatLimit);
- try {
- const earlier = await backfillOlder(oldest, want);
- transcript = restoreEarlier(transcript, earlier, want);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- }
- },
+ async showEarlier(): Promise<void> {
+ if (disposed) return;
+ const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq;
+ if (oldest <= 1) return;
+ const want = unloadCount(chatLimit);
+ try {
+ const earlier = await backfillOlder(oldest, want);
+ transcript = restoreEarlier(transcript, earlier, want);
+ _error = null;
+ } catch (err) {
+ _error = err instanceof Error ? err.message : String(err);
+ }
+ },
- resync(): void {
- if (disposed) return;
- // A turn may have sealed while we were disconnected (missed `turn-sealed`):
- // clear the now-stale spinner BEFORE re-subscribing, so a finished turn
- // doesn't spin forever. A still-running turn's replay re-asserts it.
- transcript = clearGenerating(transcript);
- void syncTail();
- void syncMetrics();
- },
+ resync(): void {
+ if (disposed) return;
+ // A turn may have sealed while we were disconnected (missed `turn-sealed`):
+ // clear the now-stale spinner BEFORE re-subscribing, so a finished turn
+ // doesn't spin forever. A still-running turn's replay re-asserts it.
+ transcript = clearGenerating(transcript);
+ void syncTail();
+ void syncMetrics();
+ },
- dispose(): void {
- disposed = true;
- },
- };
+ dispose(): void {
+ disposed = true;
+ },
+ };
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index c1d62a6..c052d1b 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -2,1548 +2,1548 @@ import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
import {
- createFakeCache,
- createFakeHistorySync,
- createFakeMetricsSync,
- createFakeTransport,
+ createFakeCache,
+ createFakeHistorySync,
+ createFakeMetricsSync,
+ createFakeTransport,
} from "./test-helpers";
const CONV_ID = "test-conv-1";
function makeStoredChunk(seq: number, role: "user" | "assistant" = "assistant"): StoredChunk {
- return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
+ return { seq, role, chunk: { type: "text", text: `chunk-${seq}` } };
}
function deltaEvent(event: AgentEvent): import("@dispatch/transport-contract").ChatDeltaMessage {
- return { type: "chat.delta", event };
+ return { type: "chat.delta", event };
}
function errorMessage(message: string): import("@dispatch/transport-contract").ChatErrorMessage {
- return { type: "chat.error", message };
+ return { type: "chat.error", message };
}
describe("createChatStore", () => {
- it("folding a chat.delta updates messages", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
- );
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
- );
-
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("assistant");
- expect(store.messages[0]?.chunks).toHaveLength(1);
- expect(store.messages[0]?.chunks[0]?.type).toBe("text");
- expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
- "Hello world",
- );
-
- store.dispose();
- });
-
- it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Set up what the history sync will return
- historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
- );
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait for the async sync to complete
- await vi.waitFor(() => {
- expect(historySync.calls).toHaveLength(1);
- });
-
- expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
-
- // Cache should have the committed chunks
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(2);
-
- // Messages should include both provisional and committed
- expect(store.messages.length).toBeGreaterThanOrEqual(1);
-
- store.dispose();
- });
-
- it("send posts a chat.send with conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello server");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.type).toBe("chat.send");
- expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
- expect(transport.sent[0]?.message).toBe("Hello server");
- expect(transport.sent[0]).not.toHaveProperty("model");
-
- store.dispose();
- });
-
- it("send posts a chat.send with model when set", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.dispose();
- });
-
- describe("queueMessage (chat.queue — steering)", () => {
- it("posts a chat.queue with conversationId + text", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage("steer left");
-
- expect(transport.sent).toHaveLength(0); // chat.send stays empty
- expect(transport.sentQueue).toHaveLength(1);
- expect(transport.sentQueue[0]?.type).toBe("chat.queue");
- expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
- expect(transport.sentQueue[0]?.text).toBe("steer left");
-
- store.dispose();
- });
-
- it("trims whitespace before sending", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage(" padded ");
-
- expect(transport.sentQueue[0]?.text).toBe("padded");
-
- store.dispose();
- });
-
- it("does not send for empty/whitespace-only text", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage(" ");
- store.queueMessage("");
-
- expect(transport.sentQueue).toHaveLength(0);
-
- store.dispose();
- });
-
- it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.queueMessage("queued steering message");
-
- expect(store.chunks).toHaveLength(0); // no transcript echo
-
- store.dispose();
- });
- });
-
- it("chat.error sets error", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.error).toBeNull();
-
- store.handleDelta(errorMessage("Something broke"));
-
- expect(store.error).toBe("Something broke");
-
- store.dispose();
- });
-
- it("load hydrates from cache then syncs the tail", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- // Pre-populate cache
- await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
-
- // History sync returns new chunks
- historySync.returnChunks = [makeStoredChunk(3, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- // Should have synced
- expect(historySync.calls).toHaveLength(1);
- expect(historySync.calls[0]?.sinceSeq).toBe(2);
-
- // Messages should include all chunks
- expect(store.messages.length).toBeGreaterThanOrEqual(2);
-
- store.dispose();
- });
-
- it("load with empty cache still syncs", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- historySync.returnChunks = [makeStoredChunk(1, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- expect(historySync.calls).toHaveLength(1);
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
-
- store.dispose();
- });
-
- it("error is cleared on successful sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // First, set an error
- store.handleDelta(errorMessage("fail"));
- expect(store.error).toBe("fail");
-
- // Now trigger a successful sync via turn-sealed
- historySync.returnChunks = [makeStoredChunk(1)];
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(store.error).toBeNull();
- });
-
- store.dispose();
- });
-
- it("dispose prevents further syncs", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.dispose();
-
- // Trigger a turn-sealed after dispose
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait a tick to let any async work settle
- await new Promise((r) => setTimeout(r, 10));
-
- // No sync should have happened
- expect(historySync.calls).toHaveLength(0);
-
- store.dispose();
- });
-
- it("overlapping syncs are guarded", async () => {
- const transport = createFakeTransport();
- const _historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- // Make the first sync slow
- let resolveFirstSync: (() => void) | undefined;
- const firstSyncPromise = new Promise<void>((resolve) => {
- resolveFirstSync = resolve;
- });
-
- let callCount = 0;
- const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
- callCount++;
- if (callCount === 1) {
- await firstSyncPromise;
- }
- return { chunks: [], latestSeq: sinceSeq };
- };
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: slowHistorySync,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Trigger first sync
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- // Wait a tick so the first sync starts
- await new Promise((r) => setTimeout(r, 0));
-
- // Trigger second sync while first is pending
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
-
- // Only one call should have been made (second was guarded)
- expect(callCount).toBe(1);
-
- // Release the first sync
- resolveFirstSync?.();
- await new Promise((r) => setTimeout(r, 10));
-
- store.dispose();
- });
-
- it("handles tool-call and tool-result chunks", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp/test.txt" },
- stepId: "t1#0" as StepId,
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "tool-result",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents",
- isError: false,
- stepId: "t1#0" as StepId,
- }),
- );
-
- expect(store.chunks).toHaveLength(2);
- expect(store.chunks[0]?.chunk.type).toBe("tool-call");
- expect(store.chunks[1]?.chunk.type).toBe("tool-result");
-
- store.dispose();
- });
-
- it("setModel changes the model used by the next send", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("First");
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.setModel("anthropic/claude-3");
- store.send("Second");
- expect(transport.sent[1]?.model).toBe("anthropic/claude-3");
-
- store.dispose();
- });
-
- it("setModel from undefined to a model", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("First");
- expect(transport.sent[0]).not.toHaveProperty("model");
-
- store.setModel("openai/gpt-4o");
- store.send("Second");
- expect(transport.sent[1]?.model).toBe("openai/gpt-4o");
-
- store.dispose();
- });
-
- it("handleDelta ignores a chat.delta for a different conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(
- deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }),
- );
- store.handleDelta(
- deltaEvent({
- type: "text-delta",
- conversationId: "other-conv",
- turnId: "t1",
- delta: "Should be ignored",
- }),
- );
-
- expect(store.messages).toHaveLength(0);
-
- store.dispose();
- });
-
- it("handleDelta ignores a chat.error for a different conversationId", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" });
-
- expect(store.error).toBeNull();
-
- store.dispose();
- });
-
- it("send optimistically shows the user message immediately", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.send("hi");
-
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("user");
- expect(store.messages[0]?.chunks).toHaveLength(1);
- expect(store.messages[0]?.chunks[0]?.type).toBe("text");
- expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi");
-
- store.dispose();
- });
-
- it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- historySync.returnChunks = [
- { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
- ];
-
- store.send("hi");
- expect(store.messages).toHaveLength(1);
- expect(store.messages[0]?.role).toBe("user");
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }),
- );
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(store.messages.length).toBe(2);
- });
-
- expect(store.messages[0]?.role).toBe("user");
- expect(store.messages[1]?.role).toBe("assistant");
-
- store.dispose();
- });
-
- it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.turnMetrics).toHaveLength(0);
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "step-complete",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 200,
- genTotalMs: 800,
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- durationMs: 1200,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- const entry = store.turnMetrics[0];
- expect(entry?.turnId).toBe("t1");
- expect(entry?.steps).toHaveLength(1);
- expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
- expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
- expect(entry?.steps[0]?.genTotalMs).toBe(800);
- expect(entry?.total).not.toBeNull();
- expect(entry?.total?.usage.inputTokens).toBe(100);
- expect(entry?.total?.usage.outputTokens).toBe(50);
- expect(entry?.total?.durationMs).toBe(1200);
-
- store.dispose();
- });
-
- it("turnMetrics entry has total: null before done (progressive turn)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "step-complete",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- ttftMs: 200,
- genTotalMs: 800,
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- const entry = store.turnMetrics[0];
- expect(entry?.turnId).toBe("t1");
- expect(entry?.steps).toHaveLength(1);
- expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
- expect(entry?.total).toBeNull();
-
- store.dispose();
- });
-
- it("metricsSync durable result overrides live by turnId", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Live fold gives some metrics
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
-
- // Durable sync returns different numbers for the same turnId
- metricsSync.returnTurns = [
- {
- turnId: "t1",
- usage: { inputTokens: 200, outputTokens: 80 },
- durationMs: 500,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 200, outputTokens: 80 },
- genTotalMs: 400,
- },
- ],
- },
- ];
-
- // Trigger metrics sync via turn-sealed
- historySync.returnChunks = [];
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- // Durable should now override live (syncMetrics is async, wait for it)
- await vi.waitFor(() => {
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
- });
-
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
-
- store.dispose();
- });
-
- it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Live fold some metrics
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({
- type: "usage",
- conversationId: CONV_ID,
- turnId: "t1",
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "done",
- conversationId: CONV_ID,
- turnId: "t1",
- reason: "end-turn",
- usage: { inputTokens: 100, outputTokens: 50 },
- }),
- );
-
- expect(store.turnMetrics).toHaveLength(1);
-
- // Make the metrics sync reject
- metricsSync.nextError = "metrics endpoint unavailable";
-
- historySync.returnChunks = [];
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
-
- await vi.waitFor(() => {
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- // Live metrics should still be intact
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
-
- // No error should have been thrown to the store
- expect(store.error).toBeNull();
-
- store.dispose();
- });
-
- it("load calls metricsSync after history sync", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
-
- metricsSync.returnTurns = [
- {
- turnId: "t1",
- usage: { inputTokens: 300, outputTokens: 100 },
- durationMs: 900,
- steps: [],
- },
- ];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- await store.load();
-
- expect(historySync.calls).toHaveLength(1);
- expect(metricsSync.calls).toHaveLength(1);
- expect(metricsSync.calls[0]).toBe(CONV_ID);
- expect(store.turnMetrics).toHaveLength(1);
- expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
-
- store.dispose();
- });
-
- it("generating reflects the turn lifecycle (idle → running → idle)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- expect(store.generating).toBe(false);
-
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- expect(store.generating).toBe(true);
-
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }),
- );
- expect(store.generating).toBe(true);
-
- store.handleDelta(
- deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
- );
- expect(store.generating).toBe(false);
-
- store.dispose();
- });
-
- it("generating lights up for a watcher whose turn was replayed (no send first)", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // A late-joiner receives the in-flight turn replayed from turn-start.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(
- deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }),
- );
- expect(store.generating).toBe(true);
- expect(transport.sent).toHaveLength(0); // it never sent — it's just watching
-
- store.dispose();
- });
-
- it("resync clears a stale generating flag and re-syncs history + metrics", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was
- // missed, so generating is stuck true.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- expect(store.generating).toBe(true);
-
- // The turn actually sealed while we were gone — history now has the chunks.
- historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
-
- store.resync();
-
- // Generating is cleared synchronously (a finished turn must not spin forever).
- expect(store.generating).toBe(false);
-
- await vi.waitFor(() => {
- expect(historySync.calls).toHaveLength(1);
- expect(metricsSync.calls).toHaveLength(1);
- });
-
- store.dispose();
- });
-
- it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // Commit exactly 100 chunks via a sealed turn (at the limit — no trim).
- const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1));
- historySync.returnChunks = hundred;
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(100);
- });
- expect(store.hasEarlier).toBe(false);
-
- // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t2",
- toolCallId: "tc1",
- toolName: "probe",
- input: {},
- stepId: "t2#0" as StepId,
- }),
- );
-
- expect(store.chunks).toHaveLength(76);
- expect(store.chunks[0]?.seq).toBe(26);
- expect(store.hasEarlier).toBe(true);
-
- store.dispose();
- });
-
- it("chat limit: unloading is deferred while the gate is closed, then catches up", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- let atBottom = false; // reader scrolled up
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 10,
- canUnload: () => atBottom,
- });
-
- // 15 live tool-calls: over the limit, but the gate defers every trim.
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- for (let i = 0; i < 15; i++) {
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: `tc${i}`,
- toolName: "probe",
- input: {},
- stepId: `t1#${i}` as StepId,
- }),
- );
- }
- expect(store.chunks).toHaveLength(15);
-
- // Reader returns to the bottom — the deferred trim now catches up.
- // With no committed chunks, it drops the oldest provisional chunks
- // (the in-flight turn) to stay within the limit.
- atBottom = true;
- store.handleDelta(
- deltaEvent({
- type: "tool-call",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc15",
- toolName: "probe",
- input: {},
- stepId: "t1#15" as StepId,
- }),
- );
- // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
- expect(store.chunks).toHaveLength(10);
-
- store.dispose();
- });
-
- it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- let atBottom = false;
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- canUnload: () => atBottom,
- });
-
- // Seal a turn committing 130 chunks while the reader is scrolled up: no trim.
- historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(130);
- });
-
- // Back at the bottom: the next fold trims whole quarters down to ≤ 100.
- atBottom = true;
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
- // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk).
- expect(store.chunks).toHaveLength(80);
- expect(store.chunks[0]?.seq).toBe(51);
-
- store.dispose();
- });
-
- it("chat limit: load windows a long cached conversation to 75% of the limit", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- // floor(100 × 0.75) = 75 newest chunks: seqs 426..500.
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(426);
- expect(store.hasEarlier).toBe(true);
- // The tail sync still used the cache's real cursor (not the window's edge).
- expect(historySync.calls[0]?.sinceSeq).toBe(500);
-
- store.dispose();
- });
-
- it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // The server holds 500 chunks; the windowed fetch returns the newest 75.
- historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`).
- expect(historySync.calls[0]?.sinceSeq).toBe(0);
- expect(historySync.calls[0]?.window).toEqual({ limit: 75 });
-
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(426);
- // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) —
- // no local watermark was ever set.
- expect(store.hasEarlier).toBe(true);
- // Only the window was shipped + cached (the point of CR-5).
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(75);
-
- store.dispose();
- });
-
- it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]);
- historySync.returnChunks = [makeStoredChunk(3)];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
-
- expect(historySync.calls[0]?.sinceSeq).toBe(2);
- expect(historySync.calls[0]?.window).toBeUndefined();
-
- store.dispose();
- });
-
- it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load(); // server-windowed: loaded + cached = 426..500
- expect(store.chunks[0]?.seq).toBe(426);
-
- await store.showEarlier();
-
- // Nothing below 426 was cached → fetched the missing run from the server.
- const backfill = historySync.calls[1];
- expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 });
- expect(store.chunks).toHaveLength(100);
- expect(store.chunks[0]?.seq).toBe(401);
- expect(store.hasEarlier).toBe(true);
- // The backfilled run is persisted: the NEXT page-in is cache-local.
- const cached = await cache.impl.load(CONV_ID);
- expect(cached).toHaveLength(100);
-
- store.dispose();
- });
-
- it("chat limit: showEarlier pages a quarter back in from the cache", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
- expect(store.chunks[0]?.seq).toBe(426);
-
- await store.showEarlier(); // +ceil(100/4) = 25 older chunks
- expect(store.chunks).toHaveLength(100);
- expect(store.chunks[0]?.seq).toBe(401);
- expect(store.hasEarlier).toBe(true);
- // The cache reached deep enough — no server backfill was needed.
- expect(historySync.calls).toHaveLength(1);
-
- store.dispose();
- });
-
- it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load(); // window 75: hidden 1..5
- expect(store.chunks).toHaveLength(75);
- expect(store.hasEarlier).toBe(true);
-
- await store.showEarlier(); // restores all 5 → nothing left below
- expect(store.chunks).toHaveLength(80);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(store.hasEarlier).toBe(false);
-
- store.dispose();
- });
-
- it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
- );
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- await store.load();
- expect(store.chunks[0]?.seq).toBe(426);
-
- // A sealed turn triggers syncTail, whose cache.commit returns the FULL
- // merged cache (seqs 1..501) — the watermark must keep 1..425 out.
- historySync.returnChunks = [makeStoredChunk(501)];
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" }));
-
- await vi.waitFor(() => {
- expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501);
- });
- expect(store.chunks[0]?.seq).toBe(426);
- expect(store.chunks).toHaveLength(76);
-
- store.dispose();
- });
-
- it("setChatLimit: lowering the limit trims older committed chunks live", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // Load 80 committed chunks (under the limit — no trim yet).
- historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(80);
- });
-
- // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs
- // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain.
- await store.setChatLimit(10);
- expect(store.chunks).toHaveLength(8);
- expect(store.chunks[0]?.seq).toBe(73);
- expect(store.hasEarlier).toBe(true);
-
- store.dispose();
- });
-
- it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200.
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
- );
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load();
- expect(store.chunks).toHaveLength(75);
- expect(store.chunks[0]?.seq).toBe(126);
- expect(store.hasEarlier).toBe(true);
-
- // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks
- // (seqs 51..125) from the cache. No server backfill (cache is deep enough).
- await store.setChatLimit(200);
- expect(historySync.calls).toHaveLength(1); // the load-time tail sync only
- expect(store.chunks).toHaveLength(150);
- expect(store.chunks[0]?.seq).toBe(51);
- expect(store.hasEarlier).toBe(true); // 51 > 1
-
- store.dispose();
- });
-
- it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
- historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load();
- expect(store.chunks[0]?.seq).toBe(126);
-
- // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill
- // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75).
- await store.setChatLimit(200);
- const backfill = historySync.calls[1];
- expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 });
- expect(store.chunks).toHaveLength(150);
- expect(store.chunks[0]?.seq).toBe(51);
-
- store.dispose();
- });
-
- it("setChatLimit: raising refills all available older history (down to the origin)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101).
- historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(76);
- });
- expect(store.chunks[0]?.seq).toBe(26);
- expect(store.hasEarlier).toBe(true);
-
- // Raise to 500 → window 375 → want 299 older. The cache holds only
- // seqs 1..25 below the window (no more server-side) → restore all 25 →
- // 101 loaded, reaching the origin.
- await store.setChatLimit(500);
- expect(store.chunks).toHaveLength(101);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(store.hasEarlier).toBe(false);
-
- store.dispose();
- });
-
- it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- await cache.impl.commit(
- CONV_ID,
- Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
- );
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
- await store.load(); // only 50 chunks → all loaded, window starts at seq 1
- expect(store.chunks).toHaveLength(50);
- expect(store.hasEarlier).toBe(false);
- const callsAfterLoad = historySync.calls.length;
-
- await store.setChatLimit(500); // raise → refill no-ops (oldest = 1)
- expect(store.chunks).toHaveLength(50);
- expect(store.chunks[0]?.seq).toBe(1);
- expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill
-
- store.dispose();
- });
-
- it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- chatLimit: 100,
- });
-
- historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1));
- store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
- store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
- await vi.waitFor(() => {
- expect(store.chunks).toHaveLength(50);
- });
-
- // NaN normalizes to the default (256). prev was 100 → raise → refill,
- // but the loaded window already starts at seq 1 (origin) → no-op.
- await store.setChatLimit(Number.NaN);
- expect(store.chunks).toHaveLength(50);
-
- store.dispose();
- });
-
- it("resync is a no-op after dispose", async () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const metricsSync = createFakeMetricsSync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.impl,
- metricsSync: metricsSync.impl,
- cache: cache.impl,
- });
-
- store.dispose();
- store.resync();
-
- await new Promise((r) => setTimeout(r, 10));
- expect(historySync.calls).toHaveLength(0);
- expect(metricsSync.calls).toHaveLength(0);
- });
+ it("folding a chat.delta updates messages", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hello" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: " world" }),
+ );
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("assistant");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe(
+ "Hello world",
+ );
+
+ store.dispose();
+ });
+
+ it("turn-sealed triggers a history sync, commits to cache, and applies merged history", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Set up what the history sync will return
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "Hi" }),
+ );
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait for the async sync to complete
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ });
+
+ expect(historySync.calls[0]?.conversationId).toBe(CONV_ID);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ // Cache should have the committed chunks
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(2);
+
+ // Messages should include both provisional and committed
+ expect(store.messages.length).toBeGreaterThanOrEqual(1);
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("Hello server");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.type).toBe("chat.send");
+ expect(transport.sent[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sent[0]?.message).toBe("Hello server");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.dispose();
+ });
+
+ it("send posts a chat.send with model when set", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ model: "openai/gpt-4",
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("Hello");
+
+ expect(transport.sent).toHaveLength(1);
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.dispose();
+ });
+
+ describe("queueMessage (chat.queue — steering)", () => {
+ it("posts a chat.queue with conversationId + text", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage("steer left");
+
+ expect(transport.sent).toHaveLength(0); // chat.send stays empty
+ expect(transport.sentQueue).toHaveLength(1);
+ expect(transport.sentQueue[0]?.type).toBe("chat.queue");
+ expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sentQueue[0]?.text).toBe("steer left");
+
+ store.dispose();
+ });
+
+ it("trims whitespace before sending", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage(" padded ");
+
+ expect(transport.sentQueue[0]?.text).toBe("padded");
+
+ store.dispose();
+ });
+
+ it("does not send for empty/whitespace-only text", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage(" ");
+ store.queueMessage("");
+
+ expect(transport.sentQueue).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("does NOT optimistically echo into the transcript (the surface carries the queue)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.queueMessage("queued steering message");
+
+ expect(store.chunks).toHaveLength(0); // no transcript echo
+
+ store.dispose();
+ });
+ });
+
+ it("chat.error sets error", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.error).toBeNull();
+
+ store.handleDelta(errorMessage("Something broke"));
+
+ expect(store.error).toBe("Something broke");
+
+ store.dispose();
+ });
+
+ it("load hydrates from cache then syncs the tail", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ // Pre-populate cache
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1, "user"), makeStoredChunk(2, "assistant")]);
+
+ // History sync returns new chunks
+ historySync.returnChunks = [makeStoredChunk(3, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ // Should have synced
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+
+ // Messages should include all chunks
+ expect(store.messages.length).toBeGreaterThanOrEqual(2);
+
+ store.dispose();
+ });
+
+ it("load with empty cache still syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ historySync.returnChunks = [makeStoredChunk(1, "assistant")];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+
+ store.dispose();
+ });
+
+ it("error is cleared on successful sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // First, set an error
+ store.handleDelta(errorMessage("fail"));
+ expect(store.error).toBe("fail");
+
+ // Now trigger a successful sync via turn-sealed
+ historySync.returnChunks = [makeStoredChunk(1)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.error).toBeNull();
+ });
+
+ store.dispose();
+ });
+
+ it("dispose prevents further syncs", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.dispose();
+
+ // Trigger a turn-sealed after dispose
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick to let any async work settle
+ await new Promise((r) => setTimeout(r, 10));
+
+ // No sync should have happened
+ expect(historySync.calls).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("overlapping syncs are guarded", async () => {
+ const transport = createFakeTransport();
+ const _historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ // Make the first sync slow
+ let resolveFirstSync: (() => void) | undefined;
+ const firstSyncPromise = new Promise<void>((resolve) => {
+ resolveFirstSync = resolve;
+ });
+
+ let callCount = 0;
+ const slowHistorySync: import("./ports").HistorySync = async (_conversationId, sinceSeq) => {
+ callCount++;
+ if (callCount === 1) {
+ await firstSyncPromise;
+ }
+ return { chunks: [], latestSeq: sinceSeq };
+ };
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: slowHistorySync,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Trigger first sync
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ // Wait a tick so the first sync starts
+ await new Promise((r) => setTimeout(r, 0));
+
+ // Trigger second sync while first is pending
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t2" }));
+
+ // Only one call should have been made (second was guarded)
+ expect(callCount).toBe(1);
+
+ // Release the first sync
+ resolveFirstSync?.();
+ await new Promise((r) => setTimeout(r, 10));
+
+ store.dispose();
+ });
+
+ it("handles tool-call and tool-result chunks", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ stepId: "t1#0" as StepId,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-result",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(2);
+ expect(store.chunks[0]?.chunk.type).toBe("tool-call");
+ expect(store.chunks[1]?.chunk.type).toBe("tool-result");
+
+ store.dispose();
+ });
+
+ it("setModel changes the model used by the next send", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ model: "openai/gpt-4",
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("First");
+ expect(transport.sent[0]?.model).toBe("openai/gpt-4");
+
+ store.setModel("anthropic/claude-3");
+ store.send("Second");
+ expect(transport.sent[1]?.model).toBe("anthropic/claude-3");
+
+ store.dispose();
+ });
+
+ it("setModel from undefined to a model", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("First");
+ expect(transport.sent[0]).not.toHaveProperty("model");
+
+ store.setModel("openai/gpt-4o");
+ store.send("Second");
+ expect(transport.sent[1]?.model).toBe("openai/gpt-4o");
+
+ store.dispose();
+ });
+
+ it("handleDelta ignores a chat.delta for a different conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(
+ deltaEvent({ type: "turn-start", conversationId: "other-conv", turnId: "t1" }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "text-delta",
+ conversationId: "other-conv",
+ turnId: "t1",
+ delta: "Should be ignored",
+ }),
+ );
+
+ expect(store.messages).toHaveLength(0);
+
+ store.dispose();
+ });
+
+ it("handleDelta ignores a chat.error for a different conversationId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta({ type: "chat.error", conversationId: "other-conv", message: "Wrong conv" });
+
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("send optimistically shows the user message immediately", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.send("hi");
+
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[0]?.chunks).toHaveLength(1);
+ expect(store.messages[0]?.chunks[0]?.type).toBe("text");
+ expect((store.messages[0]?.chunks[0] as { type: "text"; text: string }).text).toBe("hi");
+
+ store.dispose();
+ });
+
+ it("the optimistic user message is replaced after turn-sealed + history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ historySync.returnChunks = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
+ ];
+
+ store.send("hi");
+ expect(store.messages).toHaveLength(1);
+ expect(store.messages[0]?.role).toBe("user");
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hello!" }),
+ );
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(store.messages.length).toBe(2);
+ });
+
+ expect(store.messages[0]?.role).toBe("user");
+ expect(store.messages[1]?.role).toBe("assistant");
+
+ store.dispose();
+ });
+
+ it("folding usage/step-complete/done deltas exposes turnMetrics", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.turnMetrics).toHaveLength(0);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ durationMs: 1200,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.steps[0]?.usage.inputTokens).toBe(100);
+ expect(entry?.steps[0]?.genTotalMs).toBe(800);
+ expect(entry?.total).not.toBeNull();
+ expect(entry?.total?.usage.inputTokens).toBe(100);
+ expect(entry?.total?.usage.outputTokens).toBe(50);
+ expect(entry?.total?.durationMs).toBe(1200);
+
+ store.dispose();
+ });
+
+ it("turnMetrics entry has total: null before done (progressive turn)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "step-complete",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ ttftMs: 200,
+ genTotalMs: 800,
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ const entry = store.turnMetrics[0];
+ expect(entry?.turnId).toBe("t1");
+ expect(entry?.steps).toHaveLength(1);
+ expect(entry?.steps[0]?.stepId).toBe("t1#0" as StepId);
+ expect(entry?.total).toBeNull();
+
+ store.dispose();
+ });
+
+ it("metricsSync durable result overrides live by turnId", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold gives some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // Durable sync returns different numbers for the same turnId
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 200, outputTokens: 80 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 200, outputTokens: 80 },
+ genTotalMs: 400,
+ },
+ ],
+ },
+ ];
+
+ // Trigger metrics sync via turn-sealed
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Durable should now override live (syncMetrics is async, wait for it)
+ await vi.waitFor(() => {
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(80);
+ });
+
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.durationMs).toBe(500);
+
+ store.dispose();
+ });
+
+ it("rejected metricsSync leaves live metrics intact and does not throw", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Live fold some metrics
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "usage",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+ store.handleDelta(
+ deltaEvent({
+ type: "done",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ reason: "end-turn",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ }),
+ );
+
+ expect(store.turnMetrics).toHaveLength(1);
+
+ // Make the metrics sync reject
+ metricsSync.nextError = "metrics endpoint unavailable";
+
+ historySync.returnChunks = [];
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+
+ await vi.waitFor(() => {
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ // Live metrics should still be intact
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.outputTokens).toBe(50);
+
+ // No error should have been thrown to the store
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("load calls metricsSync after history sync", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+
+ metricsSync.returnTurns = [
+ {
+ turnId: "t1",
+ usage: { inputTokens: 300, outputTokens: 100 },
+ durationMs: 900,
+ steps: [],
+ },
+ ];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ await store.load();
+
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ expect(metricsSync.calls[0]).toBe(CONV_ID);
+ expect(store.turnMetrics).toHaveLength(1);
+ expect(store.turnMetrics[0]?.total?.usage.inputTokens).toBe(300);
+
+ store.dispose();
+ });
+
+ it("generating reflects the turn lifecycle (idle → running → idle)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ expect(store.generating).toBe(false);
+
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.generating).toBe(true);
+
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "hi" }),
+ );
+ expect(store.generating).toBe(true);
+
+ store.handleDelta(
+ deltaEvent({ type: "done", conversationId: CONV_ID, turnId: "t1", reason: "end-turn" }),
+ );
+ expect(store.generating).toBe(false);
+
+ store.dispose();
+ });
+
+ it("generating lights up for a watcher whose turn was replayed (no send first)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // A late-joiner receives the in-flight turn replayed from turn-start.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(
+ deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta: "partial" }),
+ );
+ expect(store.generating).toBe(true);
+ expect(transport.sent).toHaveLength(0); // it never sent — it's just watching
+
+ store.dispose();
+ });
+
+ it("resync clears a stale generating flag and re-syncs history + metrics", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // Disconnected mid-turn: turn-start seen, but the live done/turn-sealed was
+ // missed, so generating is stuck true.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ expect(store.generating).toBe(true);
+
+ // The turn actually sealed while we were gone — history now has the chunks.
+ historySync.returnChunks = [makeStoredChunk(1), makeStoredChunk(2)];
+
+ store.resync();
+
+ // Generating is cleared synchronously (a finished turn must not spin forever).
+ expect(store.generating).toBe(false);
+
+ await vi.waitFor(() => {
+ expect(historySync.calls).toHaveLength(1);
+ expect(metricsSync.calls).toHaveLength(1);
+ });
+
+ store.dispose();
+ });
+
+ it("chat limit: crossing the limit unloads the oldest quarter in one bulk pass", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // Commit exactly 100 chunks via a sealed turn (at the limit — no trim).
+ const hundred = Array.from({ length: 100 }, (_, i) => makeStoredChunk(i + 1));
+ historySync.returnChunks = hundred;
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(100);
+ });
+ expect(store.hasEarlier).toBe(false);
+
+ // The 101st chunk (a live tool-call) crosses the limit → 25 unload → 76 remain.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t2",
+ toolCallId: "tc1",
+ toolName: "probe",
+ input: {},
+ stepId: "t2#0" as StepId,
+ }),
+ );
+
+ expect(store.chunks).toHaveLength(76);
+ expect(store.chunks[0]?.seq).toBe(26);
+ expect(store.hasEarlier).toBe(true);
+
+ store.dispose();
+ });
+
+ it("chat limit: unloading is deferred while the gate is closed, then catches up", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ let atBottom = false; // reader scrolled up
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 10,
+ canUnload: () => atBottom,
+ });
+
+ // 15 live tool-calls: over the limit, but the gate defers every trim.
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ for (let i = 0; i < 15; i++) {
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: `tc${i}`,
+ toolName: "probe",
+ input: {},
+ stepId: `t1#${i}` as StepId,
+ }),
+ );
+ }
+ expect(store.chunks).toHaveLength(15);
+
+ // Reader returns to the bottom — the deferred trim now catches up.
+ // With no committed chunks, it drops the oldest provisional chunks
+ // (the in-flight turn) to stay within the limit.
+ atBottom = true;
+ store.handleDelta(
+ deltaEvent({
+ type: "tool-call",
+ conversationId: CONV_ID,
+ turnId: "t1",
+ toolCallId: "tc15",
+ toolName: "probe",
+ input: {},
+ stepId: "t1#15" as StepId,
+ }),
+ );
+ // 16 provisional, limit 10, quarter 3 → drop 6 oldest → 10 remain.
+ expect(store.chunks).toHaveLength(10);
+
+ store.dispose();
+ });
+
+ it("chat limit: a deferred trim catches up across committed history once the gate opens", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ let atBottom = false;
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ canUnload: () => atBottom,
+ });
+
+ // Seal a turn committing 130 chunks while the reader is scrolled up: no trim.
+ historySync.returnChunks = Array.from({ length: 130 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(130);
+ });
+
+ // Back at the bottom: the next fold trims whole quarters down to ≤ 100.
+ atBottom = true;
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" }));
+ // 130 → 2 quarters of 25 → 80 committed (turn-start adds no chunk).
+ expect(store.chunks).toHaveLength(80);
+ expect(store.chunks[0]?.seq).toBe(51);
+
+ store.dispose();
+ });
+
+ it("chat limit: load windows a long cached conversation to 75% of the limit", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ // floor(100 × 0.75) = 75 newest chunks: seqs 426..500.
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(426);
+ expect(store.hasEarlier).toBe(true);
+ // The tail sync still used the cache's real cursor (not the window's edge).
+ expect(historySync.calls[0]?.sinceSeq).toBe(500);
+
+ store.dispose();
+ });
+
+ it("chat limit: a cold cache (fresh browser) asks the SERVER for the 75% window (CR-5 ?limit=)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // The server holds 500 chunks; the windowed fetch returns the newest 75.
+ historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ // The cold-cache initial sync carried the window (`?sinceSeq=0&limit=75`).
+ expect(historySync.calls[0]?.sinceSeq).toBe(0);
+ expect(historySync.calls[0]?.window).toEqual({ limit: 75 });
+
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(426);
+ // hasEarlier derives from the 1-based gap-free seq contract (426 > 1) —
+ // no local watermark was ever set.
+ expect(store.hasEarlier).toBe(true);
+ // Only the window was shipped + cached (the point of CR-5).
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(75);
+
+ store.dispose();
+ });
+
+ it("chat limit: a warm cache syncs the tail UNWINDOWED (no seq gap behind the cache)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(CONV_ID, [makeStoredChunk(1), makeStoredChunk(2)]);
+ historySync.returnChunks = [makeStoredChunk(3)];
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+
+ expect(historySync.calls[0]?.sinceSeq).toBe(2);
+ expect(historySync.calls[0]?.window).toBeUndefined();
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier backfills from the server when the cache is too shallow (CR-5 ?beforeSeq=)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1));
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load(); // server-windowed: loaded + cached = 426..500
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ await store.showEarlier();
+
+ // Nothing below 426 was cached → fetched the missing run from the server.
+ const backfill = historySync.calls[1];
+ expect(backfill?.window).toEqual({ beforeSeq: 426, limit: 25 });
+ expect(store.chunks).toHaveLength(100);
+ expect(store.chunks[0]?.seq).toBe(401);
+ expect(store.hasEarlier).toBe(true);
+ // The backfilled run is persisted: the NEXT page-in is cache-local.
+ const cached = await cache.impl.load(CONV_ID);
+ expect(cached).toHaveLength(100);
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier pages a quarter back in from the cache", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ await store.showEarlier(); // +ceil(100/4) = 25 older chunks
+ expect(store.chunks).toHaveLength(100);
+ expect(store.chunks[0]?.seq).toBe(401);
+ expect(store.hasEarlier).toBe(true);
+ // The cache reached deep enough — no server backfill was needed.
+ expect(historySync.calls).toHaveLength(1);
+
+ store.dispose();
+ });
+
+ it("chat limit: showEarlier clears hasEarlier when the cache is exhausted", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load(); // window 75: hidden 1..5
+ expect(store.chunks).toHaveLength(75);
+ expect(store.hasEarlier).toBe(true);
+
+ await store.showEarlier(); // restores all 5 → nothing left below
+ expect(store.chunks).toHaveLength(80);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(store.hasEarlier).toBe(false);
+
+ store.dispose();
+ });
+
+ it("chat limit: a post-trim history sync does not resurrect unloaded chunks", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(426);
+
+ // A sealed turn triggers syncTail, whose cache.commit returns the FULL
+ // merged cache (seqs 1..501) — the watermark must keep 1..425 out.
+ historySync.returnChunks = [makeStoredChunk(501)];
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t9" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t9" }));
+
+ await vi.waitFor(() => {
+ expect(store.chunks[store.chunks.length - 1]?.seq).toBe(501);
+ });
+ expect(store.chunks[0]?.seq).toBe(426);
+ expect(store.chunks).toHaveLength(76);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: lowering the limit trims older committed chunks live", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // Load 80 committed chunks (under the limit — no trim yet).
+ historySync.returnChunks = Array.from({ length: 80 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(80);
+ });
+
+ // Lower the limit to 10: 80 → unload ceil(10/4)=3 per quarter, needs
+ // ceil((80-10)/3)=24 quarters → drop min(72, 80)=72 → 8 remain.
+ await store.setChatLimit(10);
+ expect(store.chunks).toHaveLength(8);
+ expect(store.chunks[0]?.seq).toBe(73);
+ expect(store.hasEarlier).toBe(true);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising the limit refills older history up to the fresh-load window", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // Cache holds 200 chunks; load at limit 100 → window 75 → seqs 126..200.
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load();
+ expect(store.chunks).toHaveLength(75);
+ expect(store.chunks[0]?.seq).toBe(126);
+ expect(store.hasEarlier).toBe(true);
+
+ // Raise to 200 → window floor(0.75×200)=150 → refill 75 older chunks
+ // (seqs 51..125) from the cache. No server backfill (cache is deep enough).
+ await store.setChatLimit(200);
+ expect(historySync.calls).toHaveLength(1); // the load-time tail sync only
+ expect(store.chunks).toHaveLength(150);
+ expect(store.chunks[0]?.seq).toBe(51);
+ expect(store.hasEarlier).toBe(true); // 51 > 1
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising backfills from the server when the cache is too shallow", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200.
+ historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1));
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load();
+ expect(store.chunks[0]?.seq).toBe(126);
+
+ // Raise to 200 → want 75 older. Cache only holds 126..200 → backfill
+ // seqs 51..125 from the server (CR-5 ?beforeSeq=126&limit=75).
+ await store.setChatLimit(200);
+ const backfill = historySync.calls[1];
+ expect(backfill?.window).toEqual({ beforeSeq: 126, limit: 75 });
+ expect(store.chunks).toHaveLength(150);
+ expect(store.chunks[0]?.seq).toBe(51);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising refills all available older history (down to the origin)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ // 101 chunks → one trim pass drops 25 → 76 remain (seqs 26..101).
+ historySync.returnChunks = Array.from({ length: 101 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(76);
+ });
+ expect(store.chunks[0]?.seq).toBe(26);
+ expect(store.hasEarlier).toBe(true);
+
+ // Raise to 500 → window 375 → want 299 older. The cache holds only
+ // seqs 1..25 below the window (no more server-side) → restore all 25 →
+ // 101 loaded, reaching the origin.
+ await store.setChatLimit(500);
+ expect(store.chunks).toHaveLength(101);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(store.hasEarlier).toBe(false);
+
+ store.dispose();
+ });
+
+ it("setChatLimit: raising is a no-op when the window already starts at the origin", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ await cache.impl.commit(
+ CONV_ID,
+ Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)),
+ );
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+ await store.load(); // only 50 chunks → all loaded, window starts at seq 1
+ expect(store.chunks).toHaveLength(50);
+ expect(store.hasEarlier).toBe(false);
+ const callsAfterLoad = historySync.calls.length;
+
+ await store.setChatLimit(500); // raise → refill no-ops (oldest = 1)
+ expect(store.chunks).toHaveLength(50);
+ expect(store.chunks[0]?.seq).toBe(1);
+ expect(historySync.calls).toHaveLength(callsAfterLoad); // no backfill
+
+ store.dispose();
+ });
+
+ it("setChatLimit: a nonsensical value is normalized (no crash, no trim)", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ chatLimit: 100,
+ });
+
+ historySync.returnChunks = Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1));
+ store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" }));
+ store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" }));
+ await vi.waitFor(() => {
+ expect(store.chunks).toHaveLength(50);
+ });
+
+ // NaN normalizes to the default (256). prev was 100 → raise → refill,
+ // but the loaded window already starts at seq 1 (origin) → no-op.
+ await store.setChatLimit(Number.NaN);
+ expect(store.chunks).toHaveLength(50);
+
+ store.dispose();
+ });
+
+ it("resync is a no-op after dispose", async () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.dispose();
+ store.resync();
+
+ await new Promise((r) => setTimeout(r, 10));
+ expect(historySync.calls).toHaveLength(0);
+ expect(metricsSync.calls).toHaveLength(0);
+ });
});
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index 100449f..26c5590 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -4,139 +4,139 @@ import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export interface FakeTransport {
- /** All `chat.send` messages sent through the fake transport. */
- readonly sent: ChatSendMessage[];
- /** All `chat.queue` messages sent through the fake transport. */
- readonly sentQueue: ChatQueueMessage[];
- readonly impl: ChatTransport;
+ /** All `chat.send` messages sent through the fake transport. */
+ readonly sent: ChatSendMessage[];
+ /** All `chat.queue` messages sent through the fake transport. */
+ readonly sentQueue: ChatQueueMessage[];
+ readonly impl: ChatTransport;
}
export function createFakeTransport(): FakeTransport {
- const sent: ChatSendMessage[] = [];
- const sentQueue: ChatQueueMessage[] = [];
- return {
- sent,
- sentQueue,
- impl: {
- send(msg) {
- if (msg.type === "chat.queue") {
- sentQueue.push(msg);
- } else {
- sent.push(msg);
- }
- },
- },
- };
+ const sent: ChatSendMessage[] = [];
+ const sentQueue: ChatQueueMessage[] = [];
+ return {
+ sent,
+ sentQueue,
+ impl: {
+ send(msg) {
+ if (msg.type === "chat.queue") {
+ sentQueue.push(msg);
+ } else {
+ sent.push(msg);
+ }
+ },
+ },
+ };
}
export interface FakeHistorySync {
- readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
- /** Set the chunks to return on the next call. */
- returnChunks: readonly StoredChunk[];
- readonly impl: HistorySync;
+ readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>;
+ /** Set the chunks to return on the next call. */
+ returnChunks: readonly StoredChunk[];
+ readonly impl: HistorySync;
}
export function createFakeHistorySync(): FakeHistorySync {
- const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
- let returnChunks: readonly StoredChunk[] = [];
- return {
- calls,
- get returnChunks() {
- return returnChunks;
- },
- set returnChunks(v: readonly StoredChunk[]) {
- returnChunks = v;
- },
- impl: async (conversationId, sinceSeq, window) => {
- calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
- // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
- // so store tests exercise the real windowed flows. `sinceSeq` filtering is
- // deliberately NOT applied — tests set `returnChunks` to the slice they
- // mean the server to hold past the cursor.
- let chunks = returnChunks;
- const before = window?.beforeSeq;
- if (before !== undefined) {
- chunks = chunks.filter((c) => c.seq < before);
- }
- if (window?.limit !== undefined && chunks.length > window.limit) {
- chunks = chunks.slice(-window.limit);
- }
- const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
- return { chunks, latestSeq };
- },
- };
+ const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = [];
+ let returnChunks: readonly StoredChunk[] = [];
+ return {
+ calls,
+ get returnChunks() {
+ return returnChunks;
+ },
+ set returnChunks(v: readonly StoredChunk[]) {
+ returnChunks = v;
+ },
+ impl: async (conversationId, sinceSeq, window) => {
+ calls.push({ conversationId, sinceSeq, ...(window !== undefined ? { window } : {}) });
+ // Apply the CR-5 WINDOW semantics (`beforeSeq` bound, then newest-`limit`)
+ // so store tests exercise the real windowed flows. `sinceSeq` filtering is
+ // deliberately NOT applied — tests set `returnChunks` to the slice they
+ // mean the server to hold past the cursor.
+ let chunks = returnChunks;
+ const before = window?.beforeSeq;
+ if (before !== undefined) {
+ chunks = chunks.filter((c) => c.seq < before);
+ }
+ if (window?.limit !== undefined && chunks.length > window.limit) {
+ chunks = chunks.slice(-window.limit);
+ }
+ const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq;
+ return { chunks, latestSeq };
+ },
+ };
}
export interface FakeMetricsSync {
- readonly calls: string[];
- returnTurns: import("@dispatch/wire").TurnMetrics[];
- /** If set, the next call will reject with this error. */
- nextError: string | undefined;
- readonly impl: MetricsSync;
+ readonly calls: string[];
+ returnTurns: import("@dispatch/wire").TurnMetrics[];
+ /** If set, the next call will reject with this error. */
+ nextError: string | undefined;
+ readonly impl: MetricsSync;
}
export function createFakeMetricsSync(): FakeMetricsSync {
- const calls: string[] = [];
- let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
- let nextError: string | undefined;
- return {
- calls,
- get returnTurns() {
- return returnTurns;
- },
- set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
- returnTurns = v;
- },
- get nextError() {
- return nextError;
- },
- set nextError(v: string | undefined) {
- nextError = v;
- },
- impl: async (conversationId) => {
- calls.push(conversationId);
- if (nextError !== undefined) {
- const err = nextError;
- nextError = undefined;
- throw new Error(err);
- }
- return { turns: returnTurns };
- },
- };
+ const calls: string[] = [];
+ let returnTurns: import("@dispatch/wire").TurnMetrics[] = [];
+ let nextError: string | undefined;
+ return {
+ calls,
+ get returnTurns() {
+ return returnTurns;
+ },
+ set returnTurns(v: import("@dispatch/wire").TurnMetrics[]) {
+ returnTurns = v;
+ },
+ get nextError() {
+ return nextError;
+ },
+ set nextError(v: string | undefined) {
+ nextError = v;
+ },
+ impl: async (conversationId) => {
+ calls.push(conversationId);
+ if (nextError !== undefined) {
+ const err = nextError;
+ nextError = undefined;
+ throw new Error(err);
+ }
+ return { turns: returnTurns };
+ },
+ };
}
export interface FakeCache {
- readonly store: Map<string, StoredChunk[]>;
- readonly impl: ConversationCache;
+ readonly store: Map<string, StoredChunk[]>;
+ readonly impl: ConversationCache;
}
export function createFakeCache(): FakeCache {
- const store = new Map<string, StoredChunk[]>();
- return {
- store,
- impl: {
- async load(conversationId) {
- return store.get(conversationId) ?? [];
- },
- async commit(conversationId, incoming) {
- const existing = store.get(conversationId) ?? [];
- const seen = new Set(existing.map((c) => c.seq));
- const toAppend = incoming.filter((c) => !seen.has(c.seq));
- const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
- store.set(conversationId, merged);
- return merged;
- },
- async sinceSeq(conversationId) {
- const chunks = store.get(conversationId) ?? [];
- if (chunks.length === 0) return 0;
- return Math.max(...chunks.map((c) => c.seq));
- },
- async evictIfOverBudget() {
- return [];
- },
- async delete(conversationId) {
- store.delete(conversationId);
- },
- },
- };
+ const store = new Map<string, StoredChunk[]>();
+ return {
+ store,
+ impl: {
+ async load(conversationId) {
+ return store.get(conversationId) ?? [];
+ },
+ async commit(conversationId, incoming) {
+ const existing = store.get(conversationId) ?? [];
+ const seen = new Set(existing.map((c) => c.seq));
+ const toAppend = incoming.filter((c) => !seen.has(c.seq));
+ const merged = [...existing, ...toAppend].sort((a, b) => a.seq - b.seq);
+ store.set(conversationId, merged);
+ return merged;
+ },
+ async sinceSeq(conversationId) {
+ const chunks = store.get(conversationId) ?? [];
+ if (chunks.length === 0) return 0;
+ return Math.max(...chunks.map((c) => c.seq));
+ },
+ async evictIfOverBudget() {
+ return [];
+ },
+ async delete(conversationId) {
+ store.delete(conversationId);
+ },
+ },
+ };
}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index b8b3193..a2fd944 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -10,797 +10,797 @@ import ModelSelector from "./ui/ModelSelector.svelte";
import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte";
describe("ChatView", () => {
- it("renders a message's text chunk", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "text", text: "Hello world" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Hello world")).toBeInTheDocument();
- });
-
- it("renders multiple chunks", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Hi there")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- });
-
- it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
- const chunks: RenderedChunk[] = [
- { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
- ];
-
- let resolveEarlier: (() => void) | undefined;
- const onShowEarlier = vi.fn(
- () =>
- new Promise<void>((resolve) => {
- resolveEarlier = resolve;
- }),
- );
-
- render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } });
-
- const button = screen.getByRole("button", { name: /show earlier messages/i });
- const user = userEvent.setup();
- await user.click(button);
-
- expect(onShowEarlier).toHaveBeenCalledTimes(1);
- // While the page-in is awaited the button is disabled (no double-fire).
- expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled();
-
- resolveEarlier?.();
- await vi.waitFor(() => {
- expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled();
- });
- });
-
- it("hides the show-earlier button when nothing is unloaded", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false },
- ];
-
- render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } });
-
- expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
- });
-
- it("renders tool-call chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/tmp/test.txt" },
- },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("read_file")).toBeInTheDocument();
- const pre = screen.getByText((content, element) => {
- return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
- });
- expect(pre).toBeInTheDocument();
- });
-
- it("renders tool-result chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents here",
- isError: false,
- },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("file contents here")).toBeInTheDocument();
- });
-
- it("renders error chunks with alert role", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "error", message: "Something failed" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- const alert = screen.getByRole("alert");
- expect(alert).toHaveTextContent("Something failed");
- });
-
- it("renders error chunks with code", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("Rate limited")).toBeInTheDocument();
- expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
- });
-
- it("renders system chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "system",
- chunk: { type: "system", text: "System context loaded" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- expect(screen.getByText("System context loaded")).toBeInTheDocument();
- });
-
- it("renders provisional (in-flight) chunks without any dimming", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "text", text: "Streaming..." },
- provisional: true,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- // In-flight chunks render at full opacity (no faded "disabled" look).
- const wrapper = screen.getByText("Streaming...").closest("div");
- expect(wrapper).not.toHaveClass("opacity-50");
- });
-
- it("renders empty transcript", () => {
- render(ChatView, { props: { chunks: [] } });
-
- const log = screen.getByRole("log");
- expect(log).toBeInTheDocument();
- expect(log.children).toHaveLength(0);
- });
-
- it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: 1,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "a",
- toolName: "read_file",
- input: { path: "/a" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 2,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "b",
- toolName: "list_dir",
- input: { path: "/b" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 3,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "a",
- toolName: "read_file",
- content: "contents-of-a",
- isError: false,
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks } });
-
- // Batched calls render as collapsible cards (one per call), not a list.
- const collapses = container.querySelectorAll(".collapse");
- expect(collapses).toHaveLength(2);
-
- // Both call names + the available result are shown; the result is absorbed
- // (no standalone tool-result card).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
- expect(screen.getByText("contents-of-a")).toBeInTheDocument();
- });
-
- it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think..." },
- provisional: true,
- streaming: true,
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks } });
-
- const collapse = container.querySelector(".collapse");
- expect(collapse).not.toBeNull();
- expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
- expect(collapse).not.toHaveClass("collapse-plus");
- // Visible bubble, like tool cards.
- expect(collapse).toHaveClass("bg-base-200");
- expect(collapse).toHaveClass("rounded-box");
- expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
- });
-
- it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => {
- const streaming: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "hmm" },
- provisional: true,
- streaming: true,
- },
- ];
-
- const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
-
- // Streaming: "Thinking" + loading dots.
- expect(screen.getByText("Thinking")).toBeInTheDocument();
- expect(screen.queryByText("Thoughts")).toBeNull();
- expect(container.querySelector(".loading")).not.toBeNull();
-
- // Open it.
- const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" });
- await userEvent.click(checkbox);
- expect(checkbox).toBeChecked();
-
- // Transition generating → completed/committed (seq assigned, no longer streaming).
- await rerender({
- chunks: [
- {
- seq: 1,
- role: "assistant",
- chunk: { type: "thinking", text: "hmm, all done" },
- provisional: false,
- },
- ],
- });
-
- // Completed: "Thoughts", no dots — and the open state survived the transition.
- expect(screen.getByText("Thoughts")).toBeInTheDocument();
- expect(screen.queryByText("Thinking")).toBeNull();
- expect(container.querySelector(".loading")).toBeNull();
- expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
- expect(container).toHaveTextContent("hmm, all done");
- });
-
- it("renders step and turn metrics as separate rows", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 100, outputTokens: 50 },
- durationMs: 1200,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- expect(screen.getByText("Hi")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
- expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
- expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
- });
-
- it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [],
- total: {
- turnId: "t1",
- usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 },
- steps: [],
- },
- },
- ];
-
- const { container } = render(ChatView, { props: { chunks, turnMetrics } });
-
- expect(screen.getByText("Last turn:")).toBeInTheDocument();
- expect(screen.getByText("Chat Total:")).toBeInTheDocument();
- // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge
- const badges = container.querySelectorAll(".badge");
- expect(badges).toHaveLength(2);
- for (const b of badges) {
- expect(b.textContent).toBe("93%");
- expect(b.classList.contains("badge-success")).toBe(true);
- }
- });
-
- it("renders step-metrics inline after tool group", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "bash",
- input: { command: "ls" },
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 3,
- role: "tool",
- chunk: {
- type: "tool-result",
- toolCallId: "tc1",
- toolName: "bash",
- content: "file.txt",
- isError: false,
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- {
- seq: 4,
- role: "assistant",
- chunk: { type: "text", text: "Done!" },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 80, outputTokens: 20 },
- genTotalMs: 300,
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 80, outputTokens: 20 },
- durationMs: 500,
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 80, outputTokens: 20 },
- genTotalMs: 300,
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Both step-metrics and turn-metrics render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
-
- // They are in separate elements (different rows)
- const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
- const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
- expect(stepEl).not.toBe(turnEl);
- });
-
- it("renders no metrics bubble when turnMetrics is empty", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics: [] } });
-
- expect(screen.getByText("Hi")).toBeInTheDocument();
- expect(screen.getByText("Hello!")).toBeInTheDocument();
- expect(screen.queryByText(/step 1/)).toBeNull();
- expect(screen.queryByText(/^turn/)).toBeNull();
- });
-
- it("omits null view values from metrics bubbles", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Response" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 10, outputTokens: 5 },
- },
- ],
- total: {
- turnId: "t1",
- usage: { inputTokens: 10, outputTokens: 5 },
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 10, outputTokens: 5 },
- },
- ],
- },
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Step metrics rendered
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
- // Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
- // No "null" or "undefined" in the DOM
- expect(screen.queryByText("null")).toBeNull();
- expect(screen.queryByText("undefined")).toBeNull();
- });
-
- it("renders step text but no turn total for a progressive turn (total: null)", () => {
- const chunks: RenderedChunk[] = [
- { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
- {
- seq: 2,
- role: "assistant",
- chunk: { type: "text", text: "Hello!" },
- provisional: false,
- },
- {
- seq: 3,
- role: "assistant",
- chunk: {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "test",
- input: {},
- stepId: "t1#0" as StepId,
- },
- provisional: false,
- },
- ];
-
- const turnMetrics: TurnMetricsEntry[] = [
- {
- turnId: "t1",
- steps: [
- {
- stepId: "t1#0" as StepId,
- usage: { inputTokens: 100, outputTokens: 50 },
- genTotalMs: 800,
- },
- ],
- total: null,
- },
- ];
-
- render(ChatView, { props: { chunks, turnMetrics } });
-
- // Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
-
- // Turn total should NOT render (total is null — turn still in progress)
- expect(screen.queryByText(/^turn/)).toBeNull();
- });
+ it("renders a message's text chunk", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello world" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
+ });
+
+ it("renders multiple chunks", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Hi there")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ });
+
+ it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
+ ];
+
+ let resolveEarlier: (() => void) | undefined;
+ const onShowEarlier = vi.fn(
+ () =>
+ new Promise<void>((resolve) => {
+ resolveEarlier = resolve;
+ }),
+ );
+
+ render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } });
+
+ const button = screen.getByRole("button", { name: /show earlier messages/i });
+ const user = userEvent.setup();
+ await user.click(button);
+
+ expect(onShowEarlier).toHaveBeenCalledTimes(1);
+ // While the page-in is awaited the button is disabled (no double-fire).
+ expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled();
+
+ resolveEarlier?.();
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled();
+ });
+ });
+
+ it("hides the show-earlier button when nothing is unloaded", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false },
+ ];
+
+ render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } });
+
+ expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
+ });
+
+ it("renders tool-call chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/tmp/test.txt" },
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ const pre = screen.getByText((content, element) => {
+ return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
+ });
+ expect(pre).toBeInTheDocument();
+ });
+
+ it("renders tool-result chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents here",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("file contents here")).toBeInTheDocument();
+ });
+
+ it("renders error chunks with alert role", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Something failed" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent("Something failed");
+ });
+
+ it("renders error chunks with code", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("Rate limited")).toBeInTheDocument();
+ expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument();
+ });
+
+ it("renders system chunks", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "system",
+ chunk: { type: "system", text: "System context loaded" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("System context loaded")).toBeInTheDocument();
+ });
+
+ it("renders provisional (in-flight) chunks without any dimming", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "text", text: "Streaming..." },
+ provisional: true,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ // In-flight chunks render at full opacity (no faded "disabled" look).
+ const wrapper = screen.getByText("Streaming...").closest("div");
+ expect(wrapper).not.toHaveClass("opacity-50");
+ });
+
+ it("renders empty transcript", () => {
+ render(ChatView, { props: { chunks: [] } });
+
+ const log = screen.getByRole("log");
+ expect(log).toBeInTheDocument();
+ expect(log.children).toHaveLength(0);
+ });
+
+ it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "a",
+ toolName: "read_file",
+ input: { path: "/a" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "b",
+ toolName: "list_dir",
+ input: { path: "/b" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "a",
+ toolName: "read_file",
+ content: "contents-of-a",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ // Batched calls render as collapsible cards (one per call), not a list.
+ const collapses = container.querySelectorAll(".collapse");
+ expect(collapses).toHaveLength(2);
+
+ // Both call names + the available result are shown; the result is absorbed
+ // (no standalone tool-result card).
+ expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("contents-of-a")).toBeInTheDocument();
+ });
+
+ it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "thinking", text: "Let me think..." },
+ provisional: true,
+ streaming: true,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ const collapse = container.querySelector(".collapse");
+ expect(collapse).not.toBeNull();
+ expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
+ expect(collapse).not.toHaveClass("collapse-plus");
+ // Visible bubble, like tool cards.
+ expect(collapse).toHaveClass("bg-base-200");
+ expect(collapse).toHaveClass("rounded-box");
+ expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
+ });
+
+ it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => {
+ const streaming: RenderedChunk[] = [
+ {
+ seq: null,
+ role: "assistant",
+ chunk: { type: "thinking", text: "hmm" },
+ provisional: true,
+ streaming: true,
+ },
+ ];
+
+ const { container, rerender } = render(ChatView, { props: { chunks: streaming } });
+
+ // Streaming: "Thinking" + loading dots.
+ expect(screen.getByText("Thinking")).toBeInTheDocument();
+ expect(screen.queryByText("Thoughts")).toBeNull();
+ expect(container.querySelector(".loading")).not.toBeNull();
+
+ // Open it.
+ const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" });
+ await userEvent.click(checkbox);
+ expect(checkbox).toBeChecked();
+
+ // Transition generating → completed/committed (seq assigned, no longer streaming).
+ await rerender({
+ chunks: [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "thinking", text: "hmm, all done" },
+ provisional: false,
+ },
+ ],
+ });
+
+ // Completed: "Thoughts", no dots — and the open state survived the transition.
+ expect(screen.getByText("Thoughts")).toBeInTheDocument();
+ expect(screen.queryByText("Thinking")).toBeNull();
+ expect(container.querySelector(".loading")).toBeNull();
+ expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked();
+ expect(container).toHaveTextContent("hmm, all done");
+ });
+
+ it("renders step and turn metrics as separate rows", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ durationMs: 1200,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
+ expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
+ });
+
+ it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 },
+ steps: [],
+ },
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks, turnMetrics } });
+
+ expect(screen.getByText("Last turn:")).toBeInTheDocument();
+ expect(screen.getByText("Chat Total:")).toBeInTheDocument();
+ // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge
+ const badges = container.querySelectorAll(".badge");
+ expect(badges).toHaveLength(2);
+ for (const b of badges) {
+ expect(b.textContent).toBe("93%");
+ expect(b.classList.contains("badge-success")).toBe(true);
+ }
+ });
+
+ it("renders step-metrics inline after tool group", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "bash",
+ input: { command: "ls" },
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "bash",
+ content: "file.txt",
+ isError: false,
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ {
+ seq: 4,
+ role: "assistant",
+ chunk: { type: "text", text: "Done!" },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 80, outputTokens: 20 },
+ durationMs: 500,
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 80, outputTokens: 20 },
+ genTotalMs: 300,
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Both step-metrics and turn-metrics render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+
+ // They are in separate elements (different rows)
+ const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
+ expect(stepEl).not.toBe(turnEl);
+ });
+
+ it("renders no metrics bubble when turnMetrics is empty", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics: [] } });
+
+ expect(screen.getByText("Hi")).toBeInTheDocument();
+ expect(screen.getByText("Hello!")).toBeInTheDocument();
+ expect(screen.queryByText(/step 1/)).toBeNull();
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
+
+ it("omits null view values from metrics bubbles", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Response" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ total: {
+ turnId: "t1",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 10, outputTokens: 5 },
+ },
+ ],
+ },
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics rendered
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ // Turn metrics rendered
+ expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ // No "null" or "undefined" in the DOM
+ expect(screen.queryByText("null")).toBeNull();
+ expect(screen.queryByText("undefined")).toBeNull();
+ });
+
+ it("renders step text but no turn total for a progressive turn (total: null)", () => {
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: { type: "text", text: "Hello!" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "test",
+ input: {},
+ stepId: "t1#0" as StepId,
+ },
+ provisional: false,
+ },
+ ];
+
+ const turnMetrics: TurnMetricsEntry[] = [
+ {
+ turnId: "t1",
+ steps: [
+ {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 100, outputTokens: 50 },
+ genTotalMs: 800,
+ },
+ ],
+ total: null,
+ },
+ ];
+
+ render(ChatView, { props: { chunks, turnMetrics } });
+
+ // Step metrics should render
+ expect(screen.getByText(/step 1/)).toBeInTheDocument();
+ expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+
+ // Turn total should NOT render (total is null — turn still in progress)
+ expect(screen.queryByText(/^turn/)).toBeNull();
+ });
});
describe("Composer", () => {
- it("calls onSend with the typed text and clears", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("calls onSend with the typed text and clears", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Hello world");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Hello world");
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
- expect(onSend).toHaveBeenCalledTimes(1);
- expect(onSend).toHaveBeenCalledWith("Hello world");
- expect(textarea).toHaveValue("");
- });
+ expect(onSend).toHaveBeenCalledTimes(1);
+ expect(onSend).toHaveBeenCalledWith("Hello world");
+ expect(textarea).toHaveValue("");
+ });
- it("does not call onSend with empty text", async () => {
- const onSend = vi.fn();
- const _user = userEvent.setup();
+ it("does not call onSend with empty text", async () => {
+ const onSend = vi.fn();
+ const _user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const sendButton = screen.getByRole("button", { name: "Send" });
- expect(sendButton).toBeDisabled();
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton).toBeDisabled();
- expect(onSend).not.toHaveBeenCalled();
- });
+ expect(onSend).not.toHaveBeenCalled();
+ });
- it("trims whitespace before sending", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("trims whitespace before sending", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, " hello ");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, " hello ");
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
- expect(onSend).toHaveBeenCalledWith("hello");
- });
+ expect(onSend).toHaveBeenCalledWith("hello");
+ });
- it("sends on Enter key (without Shift)", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("sends on Enter key (without Shift)", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Test message{Enter}");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Test message{Enter}");
- expect(onSend).toHaveBeenCalledWith("Test message");
- });
+ expect(onSend).toHaveBeenCalledWith("Test message");
+ });
- it("does not send on Shift+Enter", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ it("does not send on Shift+Enter", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
- render(Composer, { props: { onSend } });
+ render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
- expect(onSend).not.toHaveBeenCalled();
- });
+ expect(onSend).not.toHaveBeenCalled();
+ });
});
describe("ModelSelector", () => {
- const optionValues = (el: HTMLElement): string[] =>
- within(el)
- .getAllByRole("option")
- .map((o) => (o as HTMLOptionElement).value);
-
- it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
- const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
- render(ModelSelector, {
- props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
- });
-
- const keySelect = screen.getByRole("combobox", { name: "Key selector" });
- const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
- expect(keySelect).toHaveValue("anthropic");
- expect(modelSelect).toHaveValue("claude-3");
-
- expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
- // only the models under the selected key
- expect(optionValues(modelSelect)).toEqual(["claude-3"]);
- });
-
- it("selecting a key switches to the first model under it", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4o", onSelect },
- });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
- });
-
- it("selecting a model keeps the current key", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "openai/gpt-4o"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4", onSelect },
- });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
- });
+ const optionValues = (el: HTMLElement): string[] =>
+ within(el)
+ .getAllByRole("option")
+ .map((o) => (o as HTMLOptionElement).value);
+
+ it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => {
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"];
+ render(ModelSelector, {
+ props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(keySelect).toHaveValue("anthropic");
+ expect(modelSelect).toHaveValue("claude-3");
+
+ expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // only the models under the selected key
+ expect(optionValues(modelSelect)).toEqual(["claude-3"]);
+ });
+
+ it("selecting a key switches to the first model under it", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4o", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
+ });
+
+ it("selecting a model keeps the current key", async () => {
+ const onSelect = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4", "openai/gpt-4o"];
+
+ render(ModelSelector, {
+ props: { models, selected: "openai/gpt-4", onSelect },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o");
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
+ });
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
-
- const select = screen.getByRole("combobox", { name: "Reasoning effort" });
- expect(select).toHaveValue("high");
- expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
- // All five ladder levels are offered.
- expect(within(select).getAllByRole("option")).toHaveLength(5);
- });
-
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
-
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
- });
-
- it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
- ok: true as const,
- reasoningEffort: level,
- }));
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- expect(save).toHaveBeenCalledTimes(1);
- expect(save).toHaveBeenCalledWith("max");
- await vi.waitFor(() => {
- expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
- });
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
- });
-
- it("a failed save shows the error and reverts to the persisted value", async () => {
- const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- await vi.waitFor(() => {
- expect(screen.getByText("nope")).toBeInTheDocument();
- });
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low");
- });
-
- it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
- const save = vi.fn(
- () =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
- resolveSave = resolve;
- }),
- );
- const user = userEvent.setup();
-
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
-
- await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
-
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
-
- resolveSave?.({ ok: true, reasoningEffort: "max" });
- await vi.waitFor(() => {
- expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
- });
- });
+ it("renders null (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+
+ const select = screen.getByRole("combobox", { name: "Reasoning effort" });
+ expect(select).toHaveValue("high");
+ expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
+ // All five ladder levels are offered.
+ expect(within(select).getAllByRole("option")).toHaveLength(5);
+ });
+
+ it("renders a persisted level as selected", () => {
+ render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
+ });
+
+ it("selecting a level saves it via the injected port and confirms", async () => {
+ const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ reasoningEffort: level,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: null, save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledWith("max");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
+ });
+
+ it("a failed save shows the error and reverts to the persisted value", async () => {
+ const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ await vi.waitFor(() => {
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low");
+ });
+
+ it("disables the select while a save is in flight (no double-fire)", async () => {
+ let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ const save = vi.fn(
+ () =>
+ new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ resolveSave = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, { props: { persisted: null, save } });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
+
+ resolveSave?.({ ok: true, reasoningEffort: "max" });
+ await vi.waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
+ });
+ });
});
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index f2899c7..e72f639 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,294 +1,347 @@
<script lang="ts">
- import type { TurnProviderRetryEvent } from "@dispatch/wire";
- import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
- import {
- interleaveTurnMetrics,
- viewCacheRate,
- viewExpectedCache,
- viewStepMetrics,
- viewTurnMetrics,
- type TurnMetricsEntry,
- } from "../../../core/metrics";
- import { Markdown } from "../../markdown";
+ import type { TurnProviderRetryEvent } from "@dispatch/wire";
+ import { groupRenderedChunks, type RenderedChunk, viewProviderRetry } from "../index";
+ import {
+ interleaveTurnMetrics,
+ viewCacheRate,
+ viewExpectedCache,
+ viewStepMetrics,
+ viewTurnMetrics,
+ type TurnMetricsEntry,
+ } from "../../../core/metrics";
+ import { Markdown } from "../../markdown";
- const badgeClass = {
- success: "badge-success",
- warning: "badge-warning",
- error: "badge-error",
- } as const;
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
- let {
- chunks,
- turnMetrics = [],
- hasEarlier = false,
- onShowEarlier,
- thinkingKeyBase = 0,
- providerRetry = null,
- }: {
- chunks: readonly RenderedChunk[];
- turnMetrics?: readonly TurnMetricsEntry[];
- /** Earlier history is unloaded (chat limit) and can be paged back in. */
- hasEarlier?: boolean;
- /** Page earlier history back in; the caller owns scroll-position preservation. */
- onShowEarlier?: () => Promise<void>;
- /**
- * Ordinal base for thinking-collapse keys: the count of thinking chunks
- * unloaded by the chat limit, so the remaining ordinals don't shift (and
- * swap collapse state) when a trim removes older thinking blocks.
- */
- thinkingKeyBase?: number;
- /**
- * The latest `provider-retry` event for the current turn, or `null` when
- * no retry is pending → renders the transient yellow "retrying…" banner.
- * Never persisted (never part of the message history); coalesces to the
- * newest attempt + delay, and is cleared when content resumes / turn ends.
- */
- providerRetry?: TurnProviderRetryEvent | null;
- } = $props();
+ let {
+ chunks,
+ turnMetrics = [],
+ hasEarlier = false,
+ onShowEarlier,
+ thinkingKeyBase = 0,
+ providerRetry = null,
+ }: {
+ chunks: readonly RenderedChunk[];
+ turnMetrics?: readonly TurnMetricsEntry[];
+ /** Earlier history is unloaded (chat limit) and can be paged back in. */
+ hasEarlier?: boolean;
+ /** Page earlier history back in; the caller owns scroll-position preservation. */
+ onShowEarlier?: () => Promise<void>;
+ /**
+ * Ordinal base for thinking-collapse keys: the count of thinking chunks
+ * unloaded by the chat limit, so the remaining ordinals don't shift (and
+ * swap collapse state) when a trim removes older thinking blocks.
+ */
+ thinkingKeyBase?: number;
+ /**
+ * The latest `provider-retry` event for the current turn, or `null` when
+ * no retry is pending → renders the transient yellow "retrying…" banner.
+ * Never persisted (never part of the message history); coalesces to the
+ * newest attempt + delay, and is cleared when content resumes / turn ends.
+ */
+ providerRetry?: TurnProviderRetryEvent | null;
+ } = $props();
- // True while a show-earlier page-in is awaited (disables the button).
- let loadingEarlier = $state(false);
+ // True while a show-earlier page-in is awaited (disables the button).
+ let loadingEarlier = $state(false);
- async function showEarlier() {
- if (!onShowEarlier || loadingEarlier) return;
- loadingEarlier = true;
- try {
- await onShowEarlier();
- } finally {
- loadingEarlier = false;
- }
- }
+ async function showEarlier() {
+ if (!onShowEarlier || loadingEarlier) return;
+ loadingEarlier = true;
+ try {
+ await onShowEarlier();
+ } finally {
+ loadingEarlier = false;
+ }
+ }
- const groups = $derived(groupRenderedChunks(chunks));
+ const groups = $derived(groupRenderedChunks(chunks));
- const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
+ const rows = $derived(interleaveTurnMetrics(groups, turnMetrics));
- // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
- // survives the provisional→committed (seq null → seq N) transition, so the
- // collapse's open/close state is NOT lost when a turn seals. The ordinal
- // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing
- // older thinking blocks. (App isolates these keys per conversation via {#key}.)
- const keyedRows = $derived.by(() => {
- let thinking = thinkingKeyBase;
- return rows.map((row, i) => {
- if (row.kind === "step-metrics") {
- return { row, key: `s${row.step.stepId}` };
- }
- if (row.kind === "turn-metrics") {
- return { row, key: `m${row.turn.turnId}` };
- }
- const group = row.group;
- let key: string;
- if (group.kind === "tool-batch") {
- key = `b${group.stepId}`;
- } else if (group.chunk.chunk.type === "thinking") {
- key = `think${thinking++}`;
- } else if (group.chunk.seq != null) {
- key = `c${group.chunk.seq}`;
- } else {
- key = `p${i}`;
- }
- return { row, key };
- });
- });
+ // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that
+ // survives the provisional→committed (seq null → seq N) transition, so the
+ // collapse's open/close state is NOT lost when a turn seals. The ordinal
+ // starts at `thinkingKeyBase` so keys also survive a chat-limit trim removing
+ // older thinking blocks. (App isolates these keys per conversation via {#key}.)
+ const keyedRows = $derived.by(() => {
+ let thinking = thinkingKeyBase;
+ return rows.map((row, i) => {
+ if (row.kind === "step-metrics") {
+ return { row, key: `s${row.step.stepId}` };
+ }
+ if (row.kind === "turn-metrics") {
+ return { row, key: `m${row.turn.turnId}` };
+ }
+ const group = row.group;
+ let key: string;
+ if (group.kind === "tool-batch") {
+ key = `b${group.stepId}`;
+ } else if (group.chunk.chunk.type === "thinking") {
+ key = `think${thinking++}`;
+ } else if (group.chunk.seq != null) {
+ key = `c${group.chunk.seq}`;
+ } else {
+ key = `p${i}`;
+ }
+ return { row, key };
+ });
+ });
</script>
{#snippet chunkRow(rendered: RenderedChunk)}
- {#if rendered.role === "user"}
- <!-- User: a speech bubble, left-aligned -->
- <div class="chat chat-start">
- <div class="chat-bubble chat-bubble-primary">
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {/if}
- </div>
- </div>
- {:else if rendered.chunk.type === "thinking"}
- <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
- (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
- while generating, then "Thoughts" with no dots once complete. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle thoughts" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
- {#if rendered.streaming}
- <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
- </div>
- </div>
- </div>
- </div>
- {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
- <!-- Single tool call/result: a collapsible card (collapsed by default,
- like thinking). Title shows the tool name; content shows the
- input/output. Same chat-start grid shim as the thinking block. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "tool-call"}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{rendered.chunk.toolName}</span>
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- </div>
- {:else}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool result" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" class:text-error={rendered.chunk.isError}>
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{rendered.chunk.toolName}</span>
- {#if rendered.chunk.isError}
- <span class="badge badge-error badge-xs">error</span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
- </div>
- </div>
- {/if}
- </div>
- </div>
- {:else}
- <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
- chat-start grid as the user bubble, so it inherits identical left spacing. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div class="chat-bubble w-full bg-transparent">
- {#if rendered.chunk.type === "text"}
- <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
- {:else if rendered.chunk.type === "error"}
- <div class="text-error" role="alert">
- {rendered.chunk.message}
- {#if rendered.chunk.code}
- <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
- {/if}
- </div>
- {:else if rendered.chunk.type === "system"}
- <div class="text-sm opacity-70">{rendered.chunk.text}</div>
- {/if}
- </div>
- </div>
- {/if}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary">
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {/if}
+ </div>
+ </div>
+ {:else if rendered.chunk.type === "thinking"}
+ <!-- Thinking: a visible bubble (like tool cards), holding a checkbox collapse
+ (no arrow icon, smooth open/close). Title reads "Thinking" + loading dots
+ while generating, then "Thoughts" with no dots once complete. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle thoughts" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span>
+ {#if rendered.streaming}
+ <span class="loading loading-dots loading-sm" aria-label="Generating"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <p class="whitespace-pre-wrap">{rendered.chunk.text}</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"}
+ <!-- Single tool call/result: a collapsible card (collapsed by default,
+ like thinking). Title shows the tool name; content shows the
+ input/output. Same chat-start grid shim as the thinking block. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ {#if rendered.chunk.type === "tool-call"}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool call" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{rendered.chunk.toolName}</span>
+ </div>
+ <div class="collapse-content">
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(
+ rendered.chunk.input,
+ null,
+ 2,
+ )}</pre>
+ </div>
+ </div>
+ {:else}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool result" />
+ <div
+ class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"
+ class:text-error={rendered.chunk.isError}
+ >
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{rendered.chunk.toolName}</span>
+ {#if rendered.chunk.isError}
+ <span class="badge badge-error badge-xs">error</span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+ {:else}
+ <!-- Assistant text / system / error: an INVISIBLE speech bubble — same
+ chat-start grid as the user bubble, so it inherits identical left spacing. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ {#if rendered.chunk.type === "text"}
+ <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} />
+ {:else if rendered.chunk.type === "error"}
+ <div class="text-error" role="alert">
+ {rendered.chunk.message}
+ {#if rendered.chunk.code}
+ <span class="text-xs opacity-70">[{rendered.chunk.code}]</span>
+ {/if}
+ </div>
+ {:else if rendered.chunk.type === "system"}
+ <div class="text-sm opacity-70">{rendered.chunk.text}</div>
+ {/if}
+ </div>
+ </div>
+ {/if}
{/snippet}
<div class="flex flex-col gap-2 p-4 pl-6" role="log" aria-live="polite">
- {#if hasEarlier && onShowEarlier}
- <!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
- <div class="flex justify-center">
- <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}>
- {#if loadingEarlier}
- <span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
- Loading earlier messages…
- {:else}
- Show earlier messages
- {/if}
- </button>
- </div>
- {/if}
- {#each keyedRows as { row, key } (key)}
- {#if row.kind === "step-metrics"}
- {@const sv = viewStepMetrics(row.step, row.index)}
- <div class="chat chat-start">
- <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="text-xs opacity-70">
- {sv.label} · {sv.tokensLabel}
- {#if sv.tps} · {sv.tps}{/if}
- {#if sv.genTotal} · {sv.genTotal}{/if}
- </div>
- </div>
- </div>
- {:else if row.kind === "turn-metrics"}
- {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
- {@const lastCache = viewCacheRate(row.turn.usage)}
- {@const chatCache = viewCacheRate(row.cumulativeUsage)}
- {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
- <div class="chat chat-start">
- <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
- <div class="flex flex-col gap-1 text-xs">
- <div class="opacity-70">
- {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
- {#if turnView.tps} · {turnView.tps}{/if}
- {#if turnView.duration} · {turnView.duration}{/if}
- </div>
- <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
- <span class="flex items-center gap-1">
- <span class="opacity-70">Last turn:</span>
- <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span>
- </span>
- <span class="flex items-center gap-1">
- <span class="opacity-70">Chat Total:</span>
- <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span>
- </span>
- {#if retention}
- <span class="flex items-center gap-1">
- <span class="opacity-70">Retention:</span>
- <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
- </span>
- {/if}
- </div>
- </div>
- </div>
- </div>
- {:else if row.group.kind === "single"}
- {@render chunkRow(row.group.chunk)}
- {:else}
- <!-- Batched tool calls (one step): each entry is a collapsible card.
- Click to expand and see the input/output. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
- <div class="chat-bubble w-full bg-transparent">
- <div class="flex flex-col gap-1">
- {#each row.group.entries as entry (entry.call.toolCallId)}
- <div class="collapse w-full rounded-box bg-base-200 text-sm">
- <input type="checkbox" aria-label="Toggle tool call" />
- <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
- <svg class="h-4 w-4 opacity-60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
- <span>{entry.call.toolName}</span>
- {#if entry.result?.isError}
- <span class="badge badge-error badge-xs">error</span>
- {:else if entry.result === null}
- <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
- {/if}
- </div>
- <div class="collapse-content">
- <pre class="overflow-x-auto text-xs">{JSON.stringify(entry.call.input, null, 2)}</pre>
- {#if entry.result}
- <pre class="mt-1 max-h-96 overflow-auto text-xs" class:text-error={entry.result.isError}>{entry.result.content}</pre>
- {/if}
- </div>
- </div>
- {/each}
- </div>
- </div>
- </div>
- {/if}
- {/each}
- {#if providerRetry}
- {@const rv = viewProviderRetry(providerRetry)}
- <!-- Transient yellow warning: a provider error is being retried with backoff.
- NOT a message chunk (never persisted/replayed) — a live UI notification only,
- shown where the reply would appear. Coalesces to the newest attempt + delay,
- and is cleared (foldEvent) when content resumes or the turn ends. -->
- <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
- <div class="chat-bubble w-full bg-transparent">
- <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
- <div class="flex flex-wrap items-center gap-2 font-medium">
- <span aria-hidden="true">⚠</span>
- <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
- {#if rv.code}
- <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
- {/if}
- </div>
- <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
- </div>
- </div>
- </div>
- {/if}
+ {#if hasEarlier && onShowEarlier}
+ <!-- Chat limit: older chunks are unloaded; offer to page them back in. -->
+ <div class="flex justify-center">
+ <button class="btn btn-ghost btn-xs" disabled={loadingEarlier} onclick={showEarlier}>
+ {#if loadingEarlier}
+ <span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
+ Loading earlier messages…
+ {:else}
+ Show earlier messages
+ {/if}
+ </button>
+ </div>
+ {/if}
+ {#each keyedRows as { row, key } (key)}
+ {#if row.kind === "step-metrics"}
+ {@const sv = viewStepMetrics(row.step, row.index)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="text-xs opacity-70">
+ {sv.label} · {sv.tokensLabel}
+ {#if sv.tps}
+ · {sv.tps}{/if}
+ {#if sv.genTotal}
+ · {sv.genTotal}{/if}
+ </div>
+ </div>
+ </div>
+ {:else if row.kind === "turn-metrics"}
+ {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)}
+ {@const lastCache = viewCacheRate(row.turn.usage)}
+ {@const chatCache = viewCacheRate(row.cumulativeUsage)}
+ {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)}
+ <div class="chat chat-start">
+ <div class="chat-bubble w-full max-w-5xl bg-transparent p-0">
+ <div class="flex flex-col gap-1 text-xs">
+ <div class="opacity-70">
+ {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown})
+ {#if turnView.tps}
+ · {turnView.tps}{/if}
+ {#if turnView.duration}
+ · {turnView.duration}{/if}
+ </div>
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Last turn:</span>
+ <span class="badge badge-sm {badgeClass[lastCache.level]}">{lastCache.pct}%</span>
+ </span>
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Chat Total:</span>
+ <span class="badge badge-sm {badgeClass[chatCache.level]}">{chatCache.pct}%</span>
+ </span>
+ {#if retention}
+ <span class="flex items-center gap-1">
+ <span class="opacity-70">Retention:</span>
+ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span>
+ </span>
+ {/if}
+ </div>
+ </div>
+ </div>
+ </div>
+ {:else if row.group.kind === "single"}
+ {@render chunkRow(row.group.chunk)}
+ {:else}
+ <!-- Batched tool calls (one step): each entry is a collapsible card.
+ Click to expand and see the input/output. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="flex flex-col gap-1">
+ {#each row.group.entries as entry (entry.call.toolCallId)}
+ <div class="collapse w-full rounded-box bg-base-200 text-sm">
+ <input type="checkbox" aria-label="Toggle tool call" />
+ <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium">
+ <svg
+ class="h-4 w-4 opacity-60"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ ><path
+ d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
+ /></svg
+ >
+ <span>{entry.call.toolName}</span>
+ {#if entry.result?.isError}
+ <span class="badge badge-error badge-xs">error</span>
+ {:else if entry.result === null}
+ <span class="loading loading-spinner loading-xs" aria-label="Running"></span>
+ {/if}
+ </div>
+ <div class="collapse-content">
+ <pre class="overflow-x-auto text-xs">{JSON.stringify(
+ entry.call.input,
+ null,
+ 2,
+ )}</pre>
+ {#if entry.result}
+ <pre
+ class="mt-1 max-h-96 overflow-auto text-xs"
+ class:text-error={entry.result.isError}>{entry.result.content}</pre>
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+ {/if}
+ {/each}
+ {#if providerRetry}
+ {@const rv = viewProviderRetry(providerRetry)}
+ <!-- Transient yellow warning: a provider error is being retried with backoff.
+ NOT a message chunk (never persisted/replayed) — a live UI notification only,
+ shown where the reply would appear. Coalesces to the newest attempt + delay,
+ and is cleared (foldEvent) when content resumes or the turn ends. -->
+ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl">
+ <div class="chat-bubble w-full bg-transparent">
+ <div class="alert alert-warning flex-wrap items-start gap-2 py-2 text-sm" role="status">
+ <div class="flex flex-wrap items-center gap-2 font-medium">
+ <span aria-hidden="true">⚠</span>
+ <span>{rv.attemptLabel} — retrying in {rv.delayLabel}…</span>
+ {#if rv.code}
+ <span class="badge badge-warning badge-sm font-mono">{rv.code}</span>
+ {/if}
+ </div>
+ <div class="w-full font-mono text-xs opacity-70">{rv.message}</div>
+ </div>
+ </div>
+ </div>
+ {/if}
</div>
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte
index 7bec984..5014e5c 100644
--- a/src/features/chat/ui/CompactionView.svelte
+++ b/src/features/chat/ui/CompactionView.svelte
@@ -1,154 +1,154 @@
<script lang="ts">
- export type CompactNowResult =
- | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
- | { readonly ok: false; readonly error: string };
+ export type CompactNowResult =
+ | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
+ | { readonly ok: false; readonly error: string };
- export type SaveCompactPercentResult =
- | { readonly ok: true; readonly percent: number }
- | { readonly ok: false; readonly error: string };
+ export type SaveCompactPercentResult =
+ | { readonly ok: true; readonly percent: number }
+ | { readonly ok: false; readonly error: string };
- let {
- percent,
- canCompact,
- compactNow,
- savePercent,
- }: {
- /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */
- percent: number | null;
- /** Whether a real conversation is focused (a draft has nothing to compact). */
- canCompact: boolean;
- compactNow: () => Promise<CompactNowResult | null>;
- savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
- } = $props();
+ let {
+ percent,
+ canCompact,
+ compactNow,
+ savePercent,
+ }: {
+ /** The conversation's auto-compact percent (0-100), or null when not yet fetched. 0 = disabled. */
+ percent: number | null;
+ /** Whether a real conversation is focused (a draft has nothing to compact). */
+ canCompact: boolean;
+ compactNow: () => Promise<CompactNowResult | null>;
+ savePercent: (percent: number) => Promise<SaveCompactPercentResult | null>;
+ } = $props();
- const DEFAULT_PERCENT = 85;
+ const DEFAULT_PERCENT = 85;
- let compacting = $state(false);
- let compactError = $state<string | null>(null);
- let compactResult = $state<{ summarized: number; kept: number } | null>(null);
+ let compacting = $state(false);
+ let compactError = $state<string | null>(null);
+ let compactResult = $state<{ summarized: number; kept: number } | null>(null);
- let percentInput = $state("");
- let savingPercent = $state(false);
- let percentError = $state<string | null>(null);
- let percentSaved = $state(false);
+ let percentInput = $state("");
+ let savingPercent = $state(false);
+ let percentError = $state<string | null>(null);
+ let percentSaved = $state(false);
- // Sync the input from the prop when it changes (focus switch / initial load).
- let lastPercent = $state<number | null>(null);
- $effect(() => {
- if (percent !== lastPercent) {
- lastPercent = percent;
- percentInput = percent !== null ? String(percent) : "";
- percentError = null;
- percentSaved = false;
- }
- });
+ // Sync the input from the prop when it changes (focus switch / initial load).
+ let lastPercent = $state<number | null>(null);
+ $effect(() => {
+ if (percent !== lastPercent) {
+ lastPercent = percent;
+ percentInput = percent !== null ? String(percent) : "";
+ percentError = null;
+ percentSaved = false;
+ }
+ });
- const percentLabel = $derived(
- percent == null
- ? "Loading…"
- : percent === 0
- ? "Disabled (manual only)"
- : percent === DEFAULT_PERCENT
- ? `${percent}% (default)`
- : `${percent}%`,
- );
+ const percentLabel = $derived(
+ percent == null
+ ? "Loading…"
+ : percent === 0
+ ? "Disabled (manual only)"
+ : percent === DEFAULT_PERCENT
+ ? `${percent}% (default)`
+ : `${percent}%`,
+ );
- async function handleCompact() {
- if (compacting || !canCompact) return;
- compacting = true;
- compactError = null;
- compactResult = null;
- const result = await compactNow();
- compacting = false;
- if (result === null) return;
- if (result.ok) {
- compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
- } else {
- compactError = result.error;
- }
- }
+ async function handleCompact() {
+ if (compacting || !canCompact) return;
+ compacting = true;
+ compactError = null;
+ compactResult = null;
+ const result = await compactNow();
+ compacting = false;
+ if (result === null) return;
+ if (result.ok) {
+ compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
+ } else {
+ compactError = result.error;
+ }
+ }
- async function handleSavePercent() {
- const value = Number.parseInt(percentInput, 10);
- if (Number.isNaN(value) || value < 0 || value > 100) {
- percentError = "Must be 0-100";
- return;
- }
- savingPercent = true;
- percentError = null;
- percentSaved = false;
- const result = await savePercent(value);
- savingPercent = false;
- if (result === null) return;
- if (result.ok) {
- percentSaved = true;
- } else {
- percentError = result.error;
- }
- }
+ async function handleSavePercent() {
+ const value = Number.parseInt(percentInput, 10);
+ if (Number.isNaN(value) || value < 0 || value > 100) {
+ percentError = "Must be 0-100";
+ return;
+ }
+ savingPercent = true;
+ percentError = null;
+ percentSaved = false;
+ const result = await savePercent(value);
+ savingPercent = false;
+ if (result === null) return;
+ if (result.ok) {
+ percentSaved = true;
+ } else {
+ percentError = result.error;
+ }
+ }
</script>
<div class="flex flex-col gap-3">
- <!-- Manual compaction -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={!canCompact || compacting}
- onclick={handleCompact}
- >
- {#if compacting}
- <span class="loading loading-spinner loading-xs"></span>
- Compacting…
- {:else}
- Compact now
- {/if}
- </button>
- {#if !canCompact}
- <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
- {:else if compactError}
- <p class="text-xs text-error">{compactError}</p>
- {:else if compactResult}
- <p class="text-xs text-success">
- Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
- </p>
- {:else}
- <p class="text-xs opacity-50">
- Summarizes old messages into a system summary + retains the most recent messages.
- </p>
- {/if}
- </section>
+ <!-- Manual compaction -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canCompact || compacting}
+ onclick={handleCompact}
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact now
+ {/if}
+ </button>
+ {#if !canCompact}
+ <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
+ {:else if compactError}
+ <p class="text-xs text-error">{compactError}</p>
+ {:else if compactResult}
+ <p class="text-xs text-success">
+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
+ </p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Summarizes old messages into a system summary + retains the most recent messages.
+ </p>
+ {/if}
+ </section>
- <!-- Auto-compact percent -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
- <div class="flex items-center gap-2">
- <input
- type="number"
- class="input input-bordered input-sm w-24"
- min="0"
- max="100"
- placeholder={String(DEFAULT_PERCENT)}
- value={percentInput}
- disabled={savingPercent}
- onchange={handleSavePercent}
- aria-label="Compact percent (0-100)"
- />
- <span class="text-xs opacity-60">%</span>
- {#if savingPercent}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
- <p class="text-xs opacity-50">
- Current: {percentLabel}
- <br />
- 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
- </p>
- {#if percentError}
- <p class="text-xs text-error">{percentError}</p>
- {:else if percentSaved}
- <p class="text-xs text-success">Saved.</p>
- {/if}
- </section>
+ <!-- Auto-compact percent -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Auto-compact percent</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ min="0"
+ max="100"
+ placeholder={String(DEFAULT_PERCENT)}
+ value={percentInput}
+ disabled={savingPercent}
+ onchange={handleSavePercent}
+ aria-label="Compact percent (0-100)"
+ />
+ <span class="text-xs opacity-60">%</span>
+ {#if savingPercent}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <p class="text-xs opacity-50">
+ Current: {percentLabel}
+ <br />
+ 0 disables auto-compact. Default is {DEFAULT_PERCENT}%.
+ </p>
+ {#if percentError}
+ <p class="text-xs text-error">{percentError}</p>
+ {:else if percentSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
</div>
diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte
index fe9ea94..96b4b3a 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,198 +1,198 @@
<script lang="ts">
- import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
- const FALLBACK_CONTEXT_WINDOW = 1_000_000;
- const MAX_LINES = 7;
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
+ const MAX_LINES = 7;
- let {
- onSend,
- onQueue,
- onStop,
- contextSize = undefined,
- contextWindow = undefined,
- status = "idle",
- }: {
- onSend: (text: string) => void;
- /**
- * Enqueue a steering message (`chat.queue`). When provided AND the status
- * is `running`, the send button becomes a "Queue" button that steers the
- * in-flight turn instead of starting a new one. When absent, `onSend` is
- * used regardless (tests / non-steering contexts).
- */
- onQueue?: (text: string) => void;
- /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
- onStop?: () => void;
- // Current context occupancy (latest turn's contextSize), or `undefined`
- // when unknown — the status bar then shows "— tokens", never 0%.
- contextSize?: number | undefined;
- /** Per-model context window (max tokens) from `GET /models` modelInfo. */
- contextWindow?: number | undefined;
- // Coarse agent status for the status-bar icon.
- status?: "idle" | "running" | "error";
- } = $props();
+ let {
+ onSend,
+ onQueue,
+ onStop,
+ contextSize = undefined,
+ contextWindow = undefined,
+ status = "idle",
+ }: {
+ onSend: (text: string) => void;
+ /**
+ * Enqueue a steering message (`chat.queue`). When provided AND the status
+ * is `running`, the send button becomes a "Queue" button that steers the
+ * in-flight turn instead of starting a new one. When absent, `onSend` is
+ * used regardless (tests / non-steering contexts).
+ */
+ onQueue?: (text: string) => void;
+ /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
+ onStop?: () => void;
+ // Current context occupancy (latest turn's contextSize), or `undefined`
+ // when unknown — the status bar then shows "— tokens", never 0%.
+ contextSize?: number | undefined;
+ /** Per-model context window (max tokens) from `GET /models` modelInfo. */
+ contextWindow?: number | undefined;
+ // Coarse agent status for the status-bar icon.
+ status?: "idle" | "running" | "error";
+ } = $props();
- let text = $state("");
- let inputEl: HTMLTextAreaElement | undefined;
+ let text = $state("");
+ let inputEl: HTMLTextAreaElement | undefined;
- const hasText = $derived(text.trim().length > 0);
- const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
- const usage = $derived(computeContextUsage(contextSize, effectiveMax));
- const hasUsage = $derived(contextSize !== undefined);
+ const hasText = $derived(text.trim().length > 0);
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
+ const hasUsage = $derived(contextSize !== undefined);
- // One button, three modes:
- // - idle → "Send" (starts a turn via chat.send)
- // - running + text → "Queue" (steers via chat.queue)
- // - running + empty → "Stop" (aborts via POST /stop)
- const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
- if (status === "running" && !hasText && onStop !== undefined) return "stop";
- if (status === "running" && hasText && onQueue !== undefined) return "queue";
- return "send";
- });
- const placeholder = $derived(status === "running" ? "Steer the conversation..." : "Type a message...");
+ // One button, three modes:
+ // - idle → "Send" (starts a turn via chat.send)
+ // - running + text → "Queue" (steers via chat.queue)
+ // - running + empty → "Stop" (aborts via POST /stop)
+ const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
+ if (status === "running" && !hasText && onStop !== undefined) return "stop";
+ if (status === "running" && hasText && onQueue !== undefined) return "queue";
+ return "send";
+ });
+ const placeholder = $derived(
+ status === "running" ? "Steer the conversation..." : "Type a message...",
+ );
- // As the window fills, escalate color: calm → warning → danger.
- function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
- }
+ // As the window fills, escalate color: calm → warning → danger.
+ function fillClass(pct: number): string {
+ if (pct >= 90) return "progress-error";
+ if (pct >= 70) return "progress-warning";
+ return "progress-success";
+ }
- function resize(): void {
- const el = inputEl;
- if (!el) return;
- el.style.height = "auto";
- const style = getComputedStyle(el);
- const lineHeight = Number.parseFloat(style.lineHeight) || 20;
- const paddingY =
- Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
- const borderY =
- Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
- const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
- const next = Math.min(el.scrollHeight, maxHeight);
- el.style.height = `${next}px`;
- el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
- }
+ function resize(): void {
+ const el = inputEl;
+ if (!el) return;
+ el.style.height = "auto";
+ const style = getComputedStyle(el);
+ const lineHeight = Number.parseFloat(style.lineHeight) || 20;
+ const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
+ const borderY =
+ Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
+ const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
+ const next = Math.min(el.scrollHeight, maxHeight);
+ el.style.height = `${next}px`;
+ el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
+ }
- // Re-run resize whenever the value changes (covers programmatic clears too).
- $effect(() => {
- void text;
- resize();
- });
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
- function handleSubmit(): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- if (buttonMode === "queue") {
- onQueue?.(trimmed);
- } else {
- onSend(trimmed);
- }
- text = "";
- }
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ if (trimmed.length === 0) return;
+ if (buttonMode === "queue") {
+ onQueue?.(trimmed);
+ } else {
+ onSend(trimmed);
+ }
+ text = "";
+ }
- function handleKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
</script>
<form
- class="flex flex-col"
- onsubmit={(e) => {
- e.preventDefault();
- handleSubmit();
- }}
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
>
- <!-- Top bar: expanding textarea + single context-aware button -->
- <div class="flex items-end gap-2 px-4 pt-3 pb-2">
- <textarea
- bind:this={inputEl}
- class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
- bind:value={text}
- onkeydown={handleKeydown}
- placeholder={placeholder}
- rows="1"
- aria-label="Message input"
- ></textarea>
- {#if buttonMode === "stop"}
- <button
- class="btn btn-error w-20 shrink-0"
- type="button"
- aria-label="Stop generation"
- onclick={() => onStop?.()}
- >
- Stop
- </button>
- {:else}
- <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
- {buttonMode === "queue" ? "Queue" : "Send"}
- </button>
- {/if}
- </div>
+ <!-- Top bar: expanding textarea + single context-aware button -->
+ <div class="flex items-end gap-2 px-4 pt-3 pb-2">
+ <textarea
+ bind:this={inputEl}
+ class="textarea textarea-bordered flex-1 resize-none leading-normal !min-h-0 h-auto"
+ bind:value={text}
+ onkeydown={handleKeydown}
+ {placeholder}
+ rows="1"
+ aria-label="Message input"></textarea>
+ {#if buttonMode === "stop"}
+ <button
+ class="btn btn-error w-20 shrink-0"
+ type="button"
+ aria-label="Stop generation"
+ onclick={() => onStop?.()}
+ >
+ Stop
+ </button>
+ {:else}
+ <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!hasText}>
+ {buttonMode === "queue" ? "Queue" : "Send"}
+ </button>
+ {/if}
+ </div>
- <!-- Bottom status bar: status icon · context-window fill · token count -->
- <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
- <span class="shrink-0">
- {#if status === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
- {:else if status === "error"}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-error"
- aria-label="Error"
- >
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- {:else}
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2.5"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-4 w-4 text-success"
- aria-label="Idle"
- >
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {/if}
- </span>
+ <!-- Bottom status bar: status icon · context-window fill · token count -->
+ <div class="flex items-center gap-2 px-4 pb-2 text-xs text-base-content/50">
+ <span class="shrink-0">
+ {#if status === "running"}
+ <span class="loading loading-spinner loading-xs text-primary"></span>
+ {:else if status === "error"}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-error"
+ aria-label="Error"
+ >
+ <circle cx="12" cy="12" r="10"></circle>
+ <line x1="12" y1="8" x2="12" y2="12"></line>
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
+ </svg>
+ {:else}
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-4 w-4 text-success"
+ aria-label="Idle"
+ >
+ <polyline points="20 6 9 17 4 12"></polyline>
+ </svg>
+ {/if}
+ </span>
- {#if usage.percent !== null}
- <progress
- class="progress h-2 flex-1 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
- {/if}
+ {#if usage.percent !== null}
+ <progress
+ class="progress h-2 flex-1 {fillClass(usage.percent)}"
+ value={usage.percent}
+ max="100"
+ ></progress>
+ {:else}
+ <progress class="progress h-2 flex-1 opacity-40" value="0" max="100"></progress>
+ {/if}
- <span class="shrink-0 whitespace-nowrap font-mono">
- {#if hasUsage}
- {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
- class="text-base-content/40"
- >
- / {formatCompactTokens(usage.max)}</span
- >{/if}
- {#if usage.percent !== null}
- <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
- {/if}
- {:else}
- <span class="text-base-content/40">— tokens</span>
- {/if}
- </span>
- </div>
+ <span class="shrink-0 whitespace-nowrap font-mono">
+ {#if hasUsage}
+ {formatCompactTokens(usage.current)}{#if usage.max !== null}<span
+ class="text-base-content/40"
+ >
+ / {formatCompactTokens(usage.max)}</span
+ >{/if}
+ {#if usage.percent !== null}
+ <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
+ {/if}
+ {:else}
+ <span class="text-base-content/40">— tokens</span>
+ {/if}
+ </span>
+ </div>
</form>
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index a288cb8..03acb79 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,50 +1,50 @@
<script lang="ts">
- import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
- let {
- models,
- selected,
- onSelect,
- }: {
- models: readonly string[];
- selected: string;
- onSelect: (model: string) => void;
- } = $props();
+ let {
+ models,
+ selected,
+ onSelect,
+ }: {
+ models: readonly string[];
+ selected: string;
+ onSelect: (model: string) => void;
+ } = $props();
- const keys = $derived(modelKeys(models));
- const current = $derived(splitModelName(selected));
- const keyModels = $derived(modelsForKey(models, current.key));
+ const keys = $derived(modelKeys(models));
+ const current = $derived(splitModelName(selected));
+ const keyModels = $derived(modelsForKey(models, current.key));
- // Switching key jumps to the first model available under it.
- function selectKey(key: string): void {
- const first = modelsForKey(models, key)[0] ?? "";
- onSelect(joinModelName(key, first));
- }
+ // Switching key jumps to the first model available under it.
+ function selectKey(key: string): void {
+ const first = modelsForKey(models, key)[0] ?? "";
+ onSelect(joinModelName(key, first));
+ }
- function selectModel(model: string): void {
- onSelect(joinModelName(current.key, model));
- }
+ function selectModel(model: string): void {
+ onSelect(joinModelName(current.key, model));
+ }
</script>
<div class="flex flex-col gap-2">
- <select
- class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
- aria-label="Key selector"
- >
- {#each keys as key (key)}
- <option value={key}>{key}</option>
- {/each}
- </select>
- <select
- class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
- aria-label="Model selector"
- >
- {#each keyModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
+ <select
+ class="select w-full"
+ value={current.key}
+ onchange={(e) => selectKey(e.currentTarget.value)}
+ aria-label="Key selector"
+ >
+ {#each keys as key (key)}
+ <option value={key}>{key}</option>
+ {/each}
+ </select>
+ <select
+ class="select w-full"
+ value={current.model}
+ onchange={(e) => selectModel(e.currentTarget.value)}
+ aria-label="Model selector"
+ >
+ {#each keyModels as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ </select>
</div>
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
index 8c7b193..d982905 100644
--- a/src/features/chat/ui/ReasoningEffortSelector.svelte
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -1,75 +1,75 @@
<script lang="ts">
- import type { ReasoningEffort } from "@dispatch/transport-contract";
- import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
- } from "../reasoning-effort";
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import {
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ type SaveReasoningEffort,
+ } from "../reasoning-effort";
- let {
- persisted,
- save,
- }: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
- } = $props();
+ let {
+ persisted,
+ save,
+ }: {
+ /** The conversation's persisted level, or null when never set (default applies). */
+ persisted: ReasoningEffort | null;
+ save: SaveReasoningEffort;
+ } = $props();
- const options = effortOptions();
+ const options = effortOptions();
- // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- let chosen = $state<ReasoningEffort | null>(null);
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
+ // Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ReasoningEffort | null>(null);
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(chosen ?? effectiveEffort(persisted));
- async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
- chosen = value;
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(value);
- saving = false;
- if (result === null) return;
- if (result.ok) {
- justSaved = true;
- } else {
- error = result.error;
- chosen = null; // revert to the persisted value
- }
- }
+ async function handleChange(value: string) {
+ if (!isReasoningEffort(value) || saving) return;
+ chosen = value;
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(value);
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ justSaved = true;
+ } else {
+ error = result.error;
+ chosen = null; // revert to the persisted value
+ }
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
- <div class="flex items-center gap-2">
- <select
- class="select select-sm w-full"
- value={selected}
- disabled={saving}
- onchange={(e) => handleChange(e.currentTarget.value)}
- aria-label="Reasoning effort"
- >
- {#each options as option (option.value)}
- <option value={option.value}>{option.label}</option>
- {/each}
- </select>
- {#if saving}
- <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span>
- {/if}
- </div>
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved}
- <p class="text-xs text-success">Saved — applies from the next turn.</p>
- {:else}
- <p class="text-xs opacity-50">
- How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
- </p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
+ <div class="flex items-center gap-2">
+ <select
+ class="select select-sm w-full"
+ value={selected}
+ disabled={saving}
+ onchange={(e) => handleChange(e.currentTarget.value)}
+ aria-label="Reasoning effort"
+ >
+ {#each options as option (option.value)}
+ <option value={option.value}>{option.label}</option>
+ {/each}
+ </select>
+ {#if saving}
+ <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span>
+ {/if}
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved}
+ <p class="text-xs text-success">Saved — applies from the next turn.</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
+ </p>
+ {/if}
</div>