summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/index.ts34
-rw-r--r--src/features/chat/model-select.test.ts89
-rw-r--r--src/features/chat/model-select.ts64
-rw-r--r--src/features/chat/ports.ts43
-rw-r--r--src/features/chat/reasoning-effort.test.ts45
-rw-r--r--src/features/chat/reasoning-effort.ts66
-rw-r--r--src/features/chat/store.svelte.ts503
-rw-r--r--src/features/chat/store.test.ts2101
-rw-r--r--src/features/chat/test-helpers.ts185
-rw-r--r--src/features/chat/ui.test.ts1391
-rw-r--r--src/features/chat/ui/ChatView.svelte405
-rw-r--r--src/features/chat/ui/CompactionView.svelte154
-rw-r--r--src/features/chat/ui/Composer.svelte438
-rw-r--r--src/features/chat/ui/ModelSelector.svelte108
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte75
15 files changed, 4688 insertions, 1013 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index f1e8e29..cf57cea 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -1,7 +1,37 @@
-export type { RenderedChunk } from "../../core/chunks";
-export type { ChatTransport, HistorySync } from "./ports";
+export type {
+ ProviderRetryView,
+ RenderedChunk,
+ RenderGroup,
+ ToolBatchEntry,
+} from "../../core/chunks";
+export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
+export type { TurnMetricsEntry } from "../../core/metrics";
+export { isVisionModel } from "./model-select";
+export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
+export type {
+ EffortOption,
+ ReasoningEffortSaveResult,
+ SaveReasoningEffort,
+} from "./reasoning-effort";
+export {
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+ isReasoningEffort,
+ REASONING_EFFORT_LEVELS,
+} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
+export type { CompactNowResult, SaveCompactPercentResult } from "./ui/CompactionView.svelte";
+export { default as CompactionView } from "./ui/CompactionView.svelte";
+export type { ComposerStatus } from "./ui/Composer.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
+export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ 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
new file mode 100644
index 0000000..6d3081d
--- /dev/null
+++ b/src/features/chat/model-select.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import {
+ isVisionModel,
+ 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: "" });
+ });
+});
+
+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);
+ });
+});
+
+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([]);
+ });
+});
+
+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([]);
+ });
+});
+
+describe("isVisionModel", () => {
+ it("returns true when modelInfo[name].vision is true", () => {
+ const info = { "kimi/k2": { vision: true } };
+ expect(isVisionModel(info, "kimi/k2")).toBe(true);
+ });
+
+ it("returns false when vision is false", () => {
+ const info = { "umans/glm-5.2": { vision: false } };
+ expect(isVisionModel(info, "umans/glm-5.2")).toBe(false);
+ });
+
+ it("returns false when vision is absent (unknown)", () => {
+ const info = { "umans/glm-5.2": { contextWindow: 128000 } };
+ expect(isVisionModel(info, "umans/glm-5.2")).toBe(false);
+ });
+
+ it("returns false for a model with no metadata entry at all", () => {
+ expect(isVisionModel({}, "unknown/model")).toBe(false);
+ });
+
+ it("returns false for an empty modelInfo map", () => {
+ expect(isVisionModel({}, "kimi/k2")).toBe(false);
+ });
+});
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
new file mode 100644
index 0000000..602e0ef
--- /dev/null
+++ b/src/features/chat/model-select.ts
@@ -0,0 +1,64 @@
+import type { ModelMetadata } from "@dispatch/transport-contract";
+
+/**
+ * Pure helpers for the two-step model picker.
+ *
+ * Models arrive from `GET /models` as `<key>/<model>` strings, where `key` is
+ * the credential name (the part before the FIRST slash) and `model` is the rest.
+ * These pure functions split that into a key selector + a model selector and
+ * recombine the choice — zero DOM, zero Svelte.
+ */
+
+export interface SplitModel {
+ 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) };
+}
+
+/** 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}`;
+}
+
+/** 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;
+}
+
+/** 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;
+}
+
+/**
+ * Whether a given full model name (`<key>/<model>`) is vision-capable — i.e.
+ * `GET /models` `modelInfo[name].vision === true`. Absent/`false`/unknown →
+ * `false` (the server's vision handoff transcribes images to text for it).
+ * Pure lookup against the catalog metadata; zero DOM.
+ */
+export function isVisionModel(
+ modelInfo: Readonly<Record<string, ModelMetadata>>,
+ fullName: string,
+): boolean {
+ return modelInfo[fullName]?.vision === true;
+}
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index 07943c7..2fe10dc 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,12 +1,43 @@
-import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+import type {
+ ChatQueueMessage,
+ ChatSendMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
+} from "@dispatch/transport-contract";
-/** Injected transport port — sends chat messages to the server. */
+/**
+ * Injected transport port — sends chat messages to the server. Accepts both
+ * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message;
+ * auto-starts a turn if idle).
+ */
export interface ChatTransport {
- send(msg: ChatSendMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage): void;
}
-/** Injected history-sync port — fetches incremental history from the server. */
+/**
+ * Optional windowing for a history fetch ([email protected], CR-5).
+ * 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;
+}
+
+/**
+ * Injected history-sync port — fetches incremental history from the server
+ * (`GET /conversations/:id?sinceSeq=&beforeSeq=&limit=`). NOTE the contract
+ * caveat: on a windowed/backfill read the response's `latestSeq` describes the
+ * returned window, not the conversation's high-water mark — never regress a
+ * tail cursor from it (the FE's cursor comes from the cache's max seq, which
+ * satisfies this naturally).
+ */
export type HistorySync = (
- conversationId: string,
- sinceSeq: number,
+ conversationId: string,
+ sinceSeq: number,
+ window?: HistoryWindow,
) => Promise<ConversationHistoryResponse>;
+
+/** Injected metrics-sync port — fetches persisted per-turn metrics from the server. */
+export type MetricsSync = (conversationId: string) => Promise<ConversationMetricsResponse>;
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
new file mode 100644
index 0000000..6d409e9
--- /dev/null
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import {
+ 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("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("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("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
new file mode 100644
index 0000000..1eb77b6
--- /dev/null
+++ b/src/features/chat/reasoning-effort.ts
@@ -0,0 +1,66 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+
+/**
+ * Pure helpers for the reasoning-effort selector (the thinking-depth knob).
+ *
+ * The canonical ladder + resolution chain are SERVER-owned (`[email protected]`
+ * `ReasoningEffort`; per-turn override → persisted conversation value → default
+ * `"high"`). These helpers only shape the persisted value for display: a `null`
+ * from `GET /conversations/:id/reasoning-effort` means "never set ⇒ the default
+ * applies", so the selector shows `high (default)` — never "off". Zero DOM,
+ * zero Svelte.
+ */
+
+/** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */
+export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+];
+
+/** The server's fallback when nothing is set (the resolution chain's tail). */
+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);
+}
+
+/**
+ * The level the selector should show as selected: the persisted value, or the
+ * server default when never set (`null` = "default applies", not "off").
+ */
+export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort {
+ return persisted ?? DEFAULT_REASONING_EFFORT;
+}
+
+/** One `<option>` of the selector. */
+export interface EffortOption {
+ readonly value: ReasoningEffort;
+ readonly label: string;
+}
+
+/**
+ * The selector's options: every ladder level, with the server default marked
+ * `(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,
+ }));
+}
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's `PUT /conversations/:id/reasoning-effort` to this shape). ────────
+
+/** Outcome of `PUT /conversations/:id/reasoning-effort`. */
+export type ReasoningEffortSaveResult =
+ | { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveReasoningEffort = (
+ level: ReasoningEffort,
+) => Promise<ReasoningEffortSaveResult | null>;
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 1d8ab17..9911438 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,124 +1,409 @@
import type {
- ChatDeltaMessage,
- ChatErrorMessage,
- ChatSendMessage,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
} from "@dispatch/transport-contract";
-import type { ChatMessage } from "@dispatch/wire";
+import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
import type { RenderedChunk, TranscriptState } from "../../core/chunks";
import {
- appendUserMessage,
- applyHistory,
- foldEvent,
- initialState,
- selectChunks,
- selectMessages,
+ 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,
+} from "../../core/metrics";
import type { ConversationCache } from "../conversation-cache";
-import type { ChatTransport, HistorySync } from "./ports";
+import type { ChatTransport, HistorySync, MetricsSync } from "./ports";
export interface ChatStoreDependencies {
- readonly conversationId: string;
- readonly model?: string;
- readonly transport: ChatTransport;
- readonly historySync: HistorySync;
- readonly cache: ConversationCache;
+ 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 pendingSync: boolean;
- readonly error: string | null;
- readonly model: string | undefined;
- handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void;
- send(text: string): void;
- setModel(model: string): void;
- load(): Promise<void>;
- dispose(): void;
+ readonly messages: readonly ChatMessage[];
+ readonly chunks: readonly RenderedChunk[];
+ readonly turnMetrics: readonly TurnMetricsEntry[];
+ /**
+ * The conversation's current context size (tokens occupied) — updated
+ * PROGRESSIVELY: during an in-flight turn, the most recent step's
+ * `inputTokens + outputTokens` (each step's input already includes all prior
+ * context); once the turn seals, its authoritative `contextSize`. `undefined`
+ * ("unknown") when no step has reported usage 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 a user message (start a turn via `chat.send`). Optimistically echoes
+ * the text + any `images` as provisional user chunks (`[text, image, …]` in
+ * order), then forwards them on the WS `chat.send` op. `images` is omitted on
+ * the wire when none are staged (text-only, backward compatible). An
+ * images-only send (empty text) is allowed — the message text is `""`.
+ */
+ send(text: string, images?: readonly ImageInput[]): 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 _pendingSync = $state(false);
- let _error = $state<string | null>(null);
- let _model = $state<string | undefined>(deps.model);
- let disposed = false;
-
- async function syncTail(): Promise<void> {
- if (disposed || _pendingSync) return;
- _pendingSync = true;
- try {
- const since = await deps.cache.sinceSeq(deps.conversationId);
- const res = await deps.historySync(deps.conversationId, since);
- const merged = await deps.cache.commit(deps.conversationId, res.chunks);
- transcript = applyHistory(transcript, merged);
- _error = null;
- } catch (err) {
- _error = err instanceof Error ? err.message : String(err);
- } finally {
- _pendingSync = false;
- }
- }
-
- return {
- get messages(): readonly ChatMessage[] {
- return selectMessages(transcript);
- },
- get chunks(): readonly RenderedChunk[] {
- return selectChunks(transcript);
- },
- get pendingSync(): boolean {
- return _pendingSync;
- },
- get error(): string | null {
- return _error;
- },
- get model(): string | undefined {
- return _model;
- },
-
- 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);
- if (transcript.sealedTurnId !== null) {
- void syncTail();
- }
- },
-
- send(text: string): void {
- transcript = appendUserMessage(transcript, text);
- const msg: ChatSendMessage = {
- type: "chat.send",
- conversationId: deps.conversationId,
- message: text,
- ...(_model !== undefined ? { model: _model } : {}),
- };
- deps.transport.send(msg);
- },
-
- setModel(model: string): void {
- _model = model;
- },
-
- async load(): Promise<void> {
- const cached = await deps.cache.load(deps.conversationId);
- if (cached.length > 0) {
- transcript = applyHistory(transcript, cached);
- }
- await syncTail();
- },
-
- dispose(): void {
- disposed = true;
- },
- };
+ 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);
+
+ /**
+ * 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;
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * 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;
+ },
+
+ 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, images?: readonly ImageInput[]): void {
+ transcript = appendUserMessage(transcript, text, images);
+ maybeTrim();
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: deps.conversationId,
+ message: text,
+ ...(_model !== undefined ? { model: _model } : {}),
+ ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}),
+ ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}),
+ };
+ 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;
+ },
+
+ 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 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();
+ },
+
+ dispose(): void {
+ disposed = true;
+ },
+ };
}
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index de60b14..8f36994 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -1,497 +1,1636 @@
-import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire";
import { describe, expect, it, vi } from "vitest";
import { createChatStore } from "./store.svelte";
-import { createFakeCache, createFakeHistorySync, createFakeTransport } from "./test-helpers";
+import {
+ 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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.impl,
- cache: cache.impl,
- });
-
- store.send("Hello");
-
- expect(transport.sent).toHaveLength(1);
- expect(transport.sent[0]?.model).toBe("openai/gpt-4");
-
- store.dispose();
- });
-
- it("chat.error sets error", () => {
- const transport = createFakeTransport();
- const historySync = createFakeHistorySync();
- const cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 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,
- 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 cache = createFakeCache();
-
- historySync.returnChunks = [makeStoredChunk(1, "assistant")];
-
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 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,
- 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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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" },
- }),
- );
- store.handleDelta(
- deltaEvent({
- type: "tool-result",
- conversationId: CONV_ID,
- turnId: "t1",
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents",
- isError: false,
- }),
- );
-
- 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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- model: "openai/gpt-4",
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 cache = createFakeCache();
- const store = createChatStore({
- conversationId: CONV_ID,
- transport: transport.impl,
- historySync: historySync.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 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();
+ });
+
+ it("send forwards staged images on chat.send and echoes them provisionally", () => {
+ 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,
+ });
+
+ const images: ImageInput[] = [
+ { url: "data:image/png;base64,AAAA", mimeType: "image/png" },
+ { url: "https://example.com/cat.jpg" },
+ ];
+ store.send("look at this", images);
+
+ expect(transport.sent).toHaveLength(1);
+ const msg = transport.sent[0];
+ expect(msg?.type).toBe("chat.send");
+ expect(msg?.message).toBe("look at this");
+ expect(msg?.images).toEqual(images);
+
+ // Optimistic echo: a text chunk + two image chunks, provisional.
+ const chunks = store.chunks;
+ expect(chunks).toHaveLength(3);
+ expect(chunks[0]?.chunk).toEqual({ type: "text", text: "look at this" });
+ expect(chunks[1]?.chunk).toEqual({ type: "image", url: images[0]?.url, mimeType: "image/png" });
+ expect(chunks[2]?.chunk).toEqual({ type: "image", url: images[1]?.url });
+
+ store.dispose();
+ });
+
+ it("send omits images on the wire when none are staged (backward compatible)", () => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+
+ store.send("just text");
+
+ expect(transport.sent[0]).not.toHaveProperty("images");
+ store.dispose();
+ });
+
+ it("send omits images on the wire for an empty array", () => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+
+ store.send("just text", []);
+
+ expect(transport.sent[0]).not.toHaveProperty("images");
+ store.dispose();
+ });
+
+ it("send allows an images-only message (empty text)", () => {
+ const transport = createFakeTransport();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: createFakeHistorySync().impl,
+ metricsSync: createFakeMetricsSync().impl,
+ cache: createFakeCache().impl,
+ });
+
+ store.send("", [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }]);
+
+ expect(transport.sent[0]?.message).toBe("");
+ expect(transport.sent[0]?.images).toHaveLength(1);
+ // The echo is image-only (no text chunk).
+ expect(store.chunks).toHaveLength(1);
+ expect(store.chunks[0]?.chunk.type).toBe("image");
+ 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 d37b59e..26c5590 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -1,83 +1,142 @@
+import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
-import type { ChatTransport, HistorySync } from "./ports";
+import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export interface FakeTransport {
- readonly sent: import("@dispatch/transport-contract").ChatSendMessage[];
- 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: import("@dispatch/transport-contract").ChatSendMessage[] = [];
- return {
- sent,
- impl: {
- send(msg) {
- 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 }>;
- /** 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 }> = [];
- let returnChunks: readonly StoredChunk[] = [];
- return {
- calls,
- get returnChunks() {
- return returnChunks;
- },
- set returnChunks(v: readonly StoredChunk[]) {
- returnChunks = v;
- },
- impl: async (conversationId, sinceSeq) => {
- calls.push({ conversationId, sinceSeq });
- const chunks = returnChunks;
- 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;
+}
+
+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 };
+ },
+ };
}
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 ac8f640..5f8067d 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -1,298 +1,1165 @@
-import { render, screen } from "@testing-library/svelte";
+import type { StepId } from "@dispatch/wire";
+import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
+import type { TurnMetricsEntry } from "../../core/metrics";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
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();
- expect(screen.getByText("assistant")).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("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("marks provisional chunks", () => {
- const chunks: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "text", text: "Streaming..." },
- provisional: true,
- },
- ];
-
- render(ChatView, { props: { chunks } });
-
- const bubble = screen.getByText("Streaming...").closest(".chat-bubble");
- expect(bubble).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("thinking <details> stays open across a streaming update", async () => {
- const initial: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think..." },
- provisional: true,
- },
- ];
-
- const { rerender } = render(ChatView, { props: { chunks: initial } });
-
- const details = screen.getByText("Thinking").closest("details");
- expect(details).not.toBeNull();
- expect(details).not.toHaveAttribute("open");
- if (details) details.open = true;
- expect(details).toHaveAttribute("open");
-
- const updated: RenderedChunk[] = [
- {
- seq: null,
- role: "assistant",
- chunk: { type: "thinking", text: "Let me think... step by step" },
- provisional: true,
- },
- ];
- await rerender({ chunks: updated });
-
- const detailsAfter = screen.getByText("Thinking").closest("details");
- expect(detailsAfter).not.toBeNull();
- expect(detailsAfter).toHaveAttribute("open");
- expect(detailsAfter).toHaveTextContent("Let me think... step by step");
- });
+ 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 user image chunk as an <img> with the chunk's url", () => {
+ const url = "data:image/png;base64,AAAA";
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "image", url, mimeType: "image/png" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ const img = container.querySelector("img");
+ expect(img).not.toBeNull();
+ expect(img?.getAttribute("src")).toBe(url);
+ expect(img?.getAttribute("alt")).toBe("image/png");
+ expect(img?.getAttribute("loading")).toBe("lazy");
+ });
+
+ it("resolves a persisted image chunk's relative url against apiBaseUrl", () => {
+ // Persisted image chunks now carry a compact relative path (`/images/…`)
+ // served by the backend — prepend the API base to render them.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "image", url: "/images/conv-123/abc-456.png", mimeType: "image/png" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/images/conv-123/abc-456.png",
+ );
+ });
+
+ it("passes a data URL through unchanged even with apiBaseUrl set (optimistic echo)", () => {
+ // The optimistic echo (what the FE just sent) is still a data URL; it must
+ // NOT be mangled by the base-URL prepend.
+ const dataUrl = "data:image/png;base64,iVBOR=";
+ const chunks: RenderedChunk[] = [
+ { seq: null, role: "user", chunk: { type: "image", url: dataUrl }, provisional: true },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(dataUrl);
+ });
+
+ it("leaves a relative image url root-relative when apiBaseUrl is absent", () => {
+ // No apiBaseUrl → a browser resolves `/images/…` against the document origin.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "image", url: "/images/conv-1/x.png" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe("/images/conv-1/x.png");
+ });
+
+ it("renders a multi-chunk user message [text, image] and a transcription text", () => {
+ // A non-vision model: the server persists the original image chunk AND a
+ // transcription text chunk in the SAME user message — render both.
+ const url = "data:image/png;base64,BBQ=";
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "describe this" }, provisional: false },
+ {
+ seq: 2,
+ role: "user",
+ chunk: { type: "image", url, mimeType: "image/png" },
+ provisional: false,
+ },
+ {
+ seq: 3,
+ role: "user",
+ chunk: { type: "text", text: "[Image analysis (via kimi/k2)]: a red square" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText("describe this")).toBeInTheDocument();
+ expect(screen.getByText(/\[Image analysis/)).toBeInTheDocument();
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(url);
+ });
+
+ it("renders a consult_vision tool call/result like any other tool", () => {
+ // read_image is GONE — replaced by consult_vision (opens a vision-model
+ // conversation, attaches the image + question, returns the answer). It is
+ // a normal tool call: rendered generically by toolName.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "consult_vision",
+ input: { question: "what is in this image?", imageIds: [1] },
+ },
+ provisional: false,
+ },
+ {
+ seq: 2,
+ role: "tool",
+ chunk: {
+ type: "tool-result",
+ toolCallId: "tc1",
+ toolName: "consult_vision",
+ content: "a red square on a white background",
+ isError: false,
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0);
+ expect(screen.getByText("a red square on a white background")).toBeInTheDocument();
+ });
+
+ it("renders a non-vision placeholder text chunk as-is", () => {
+ // A non-vision model gets a numbered placeholder (a regular text chunk)
+ // instead of an auto-transcription. Renders like any text chunk.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: {
+ type: "text",
+ text: "[Image 1 attached — call consult_vision with imageIds=[1] and a specific question to analyze it]",
+ },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText(/\[Image 1 attached/)).toBeInTheDocument();
+ expect(screen.getByText(/consult_vision with imageIds/)).toBeInTheDocument();
+ });
+
+ it("renders a compacted-image text chunk as-is", () => {
+ // Image compaction transcribes old images to [Compacted image]: <desc>.
+ // Regular text chunk — render as-is.
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "user",
+ chunk: { type: "text", text: "[Compacted image]: a chart showing rising sales" },
+ provisional: false,
+ },
+ ];
+
+ render(ChatView, { props: { chunks } });
+
+ expect(screen.getByText(/\[Compacted image\]/)).toBeInTheDocument();
+ expect(screen.getByText(/rising sales/)).toBeInTheDocument();
+ });
});
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 } });
+
+ 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);
+
+ expect(onSend).toHaveBeenCalledTimes(1);
+ expect(onSend).toHaveBeenCalledWith("Hello world", undefined);
+ expect(textarea).toHaveValue("");
+ });
+
+ it("does not call onSend with empty text", async () => {
+ const onSend = vi.fn();
+ const _user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ expect(sendButton).toBeDisabled();
+
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("trims whitespace before sending", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, " hello ");
+
+ const sendButton = screen.getByRole("button", { name: "Send" });
+ await user.click(sendButton);
+
+ expect(onSend).toHaveBeenCalledWith("hello", undefined);
+ });
+
+ it("sends on Enter key (without Shift)", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Test message{Enter}");
+
+ expect(onSend).toHaveBeenCalledWith("Test message", undefined);
+ });
+
+ it("does not send on Shift+Enter", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+
+ render(Composer, { props: { onSend } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("stages a pasted image and forwards it on send", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- render(Composer, { props: { onSend } });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "look at this");
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Hello world");
+ // jsdom has no ClipboardEvent/DataTransfer: dispatch a plain paste event
+ // carrying a mock clipboardData whose only item is an image File.
+ const file = new File(["PNG"], "shot.png", { type: "image/png" });
+ const paste = new Event("paste", { bubbles: true });
+ Object.defineProperty(paste, "clipboardData", {
+ value: {
+ items: [{ kind: "file", type: "image/png", getAsFile: () => file }],
+ },
+ });
+ container.querySelector("textarea")?.dispatchEvent(paste);
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
- expect(onSend).toHaveBeenCalledTimes(1);
- expect(onSend).toHaveBeenCalledWith("Hello world");
- expect(textarea).toHaveValue("");
- });
+ await user.click(screen.getByRole("button", { name: "Send" }));
- it("does not call onSend with empty text", async () => {
- const onSend = vi.fn();
- const _user = userEvent.setup();
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images).toHaveLength(1);
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ expect(images[0]?.mimeType).toBe("image/png");
+ });
- render(Composer, { props: { onSend } });
+ it("lets a text paste proceed when no image is on the clipboard", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- const sendButton = screen.getByRole("button", { name: "Send" });
- expect(sendButton).toBeDisabled();
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "hello");
- expect(onSend).not.toHaveBeenCalled();
- });
+ // A text-only paste: no file items → the component must NOT preventDefault,
+ // so the default text paste path is unaffected (no image staged).
+ const paste = new Event("paste", { bubbles: true });
+ Object.defineProperty(paste, "clipboardData", {
+ value: { items: [{ kind: "string", type: "text/plain" }] },
+ });
+ container.querySelector("textarea")?.dispatchEvent(paste);
- it("trims whitespace before sending", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
- render(Composer, { props: { onSend } });
+ it("stages an image via the attach button's file picker", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, " hello ");
+ const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
- const sendButton = screen.getByRole("button", { name: "Send" });
- await user.click(sendButton);
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
- expect(onSend).toHaveBeenCalledWith("hello");
- });
+ // Image-only send (no text): the Send button is enabled.
+ const send = screen.getByRole("button", { name: "Send" });
+ expect(send).not.toBeDisabled();
+ await user.click(send);
- it("sends on Enter key (without Shift)", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [text, images] = onSend.mock.calls[0] ?? [];
+ expect(text).toBe("");
+ expect(images).toHaveLength(1);
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ });
- render(Composer, { props: { onSend } });
+ it("removes a staged image via the remove button", async () => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Test message{Enter}");
+ const file = new File(["PNG"], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
- expect(onSend).toHaveBeenCalledWith("Test message");
- });
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+ await user.click(screen.getByRole("button", { name: "Remove image" }));
- it("does not send on Shift+Enter", async () => {
- const onSend = vi.fn();
- const user = userEvent.setup();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ // With no text and no images, Send is disabled again.
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ });
- render(Composer, { props: { onSend } });
+ it("ignores a non-image file chosen via the picker", async () => {
+ const onSend = vi.fn();
+ const { container } = render(Composer, { props: { onSend } });
- const textarea = screen.getByRole("textbox", { name: "Message input" });
- await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2");
+ const file = new File(["TXT"], "notes.txt", { type: "text/plain" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
- expect(onSend).not.toHaveBeenCalled();
- });
+ // Give the async staging a chance; a non-image is skipped.
+ await new Promise((r) => setTimeout(r, 0));
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it("queues (steers) text-only and never forwards images", async () => {
+ // While running, the Send button becomes "Queue"; steering is text-only.
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "steer here");
+
+ // Also stage an image — it must NOT be forwarded on a queue.
+ const file = new File(["PNG"], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onSend).not.toHaveBeenCalled();
+ });
});
describe("ModelSelector", () => {
- it("renders the options and current selection", () => {
- const models = ["openai/gpt-4", "anthropic/claude-3", "google/gemini"];
- render(ModelSelector, {
- props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() },
- });
-
- const select = screen.getByRole("combobox", { name: "Model selector" });
- expect(select).toBeInTheDocument();
- expect(select).toHaveValue("anthropic/claude-3");
-
- const options = screen.getAllByRole("option");
- expect(options).toHaveLength(3);
- expect(options[0]).toHaveValue("openai/gpt-4");
- expect(options[1]).toHaveValue("anthropic/claude-3");
- expect(options[2]).toHaveValue("google/gemini");
- });
-
- it("calls onSelect on change", async () => {
- const onSelect = vi.fn();
- const user = userEvent.setup();
- const models = ["openai/gpt-4", "anthropic/claude-3"];
-
- render(ModelSelector, {
- props: { models, selected: "openai/gpt-4", onSelect },
- });
-
- const select = screen.getByRole("combobox", { name: "Model selector" });
- await user.selectOptions(select, "anthropic/claude-3");
-
- expect(onSelect).toHaveBeenCalledTimes(1);
- expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3");
- });
+ 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");
+ });
+
+ it("marks vision-capable models in the model dropdown", () => {
+ const models = ["kimi/k2", "kimi/k1.5"];
+ const modelInfo = {
+ "kimi/k2": { vision: true },
+ "kimi/k1.5": { vision: false },
+ };
+ render(ModelSelector, {
+ props: { models, selected: "kimi/k2", onSelect: vi.fn(), modelInfo },
+ });
+
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ const options = within(modelSelect).getAllByRole("option");
+ expect(options).toHaveLength(2);
+ expect(options[0]?.textContent).toContain("vision");
+ expect(options[1]?.textContent).not.toContain("vision");
+ });
+
+ it("shows the vision indicator when the selected model is vision-capable", () => {
+ render(ModelSelector, {
+ props: {
+ models: ["kimi/k2"],
+ selected: "kimi/k2",
+ onSelect: vi.fn(),
+ modelInfo: { "kimi/k2": { vision: true } },
+ },
+ });
+ expect(screen.getByText(/sees images natively/)).toBeInTheDocument();
+ });
+
+ it("shows the vision-handoff hint when the selected model is non-vision", () => {
+ render(ModelSelector, {
+ props: {
+ models: ["umans/glm-5.2"],
+ selected: "umans/glm-5.2",
+ onSelect: vi.fn(),
+ modelInfo: { "umans/glm-5.2": { vision: false } },
+ },
+ });
+ expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
+ expect(screen.queryByText(/sees images natively/)).not.toBeInTheDocument();
+ });
+
+ it("shows the handoff hint when modelInfo is absent", () => {
+ render(ModelSelector, {
+ props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() },
+ });
+ expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
+ });
+});
+
+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();
+ });
+ });
});
diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte
index cb6069b..e67ca5b 100644
--- a/src/features/chat/ui/ChatView.svelte
+++ b/src/features/chat/ui/ChatView.svelte
@@ -1,47 +1,368 @@
<script lang="ts">
- import type { RenderedChunk } from "../index";
+ import type { TurnProviderRetryEvent } from "@dispatch/wire";
+ import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index";
+ import {
+ interleaveTurnMetrics,
+ viewCacheRate,
+ viewExpectedCache,
+ viewStepMetrics,
+ viewTurnMetrics,
+ type TurnMetricsEntry,
+ } from "../../../core/metrics";
+ import { Markdown } from "../../markdown";
- let { chunks }: { chunks: readonly RenderedChunk[] } = $props();
+ const badgeClass = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ } as const;
+
+ let {
+ chunks,
+ turnMetrics = [],
+ hasEarlier = false,
+ onShowEarlier,
+ thinkingKeyBase = 0,
+ providerRetry = null,
+ apiBaseUrl = "",
+ }: {
+ 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;
+ /**
+ * The HTTP API base URL (e.g. `http://localhost:24203`). Persisted image
+ * chunks carry a compact relative path (`/images/<conv>/<uuid>.png`); this
+ * base is prepended to render them. The optimistic echo's data URL and any
+ * absolute URL pass through unchanged (see `resolveImageUrl`). Defaults to
+ * "" (root-relative — a browser resolves `/images/…` against its origin).
+ */
+ apiBaseUrl?: string;
+ } = $props();
+
+ // 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;
+ }
+ }
+
+ const groups = $derived(groupRenderedChunks(chunks));
+
+ 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 };
+ });
+ });
</script>
-<div class="flex flex-col gap-2 p-4" role="log" aria-live="polite">
- {#each chunks as rendered, i (rendered.seq != null ? `c${rendered.seq}` : `p${i}`)}
- <div class="chat {rendered.role === 'user' ? 'chat-start' : 'chat-end'}">
- <div class="chat-header text-xs opacity-70">{rendered.role}</div>
- <div
- class="chat-bubble"
- class:chat-bubble-primary={rendered.role === "user"}
- class:chat-bubble-secondary={rendered.role === "assistant"}
- class:opacity-50={rendered.provisional}
- >
- {#if rendered.chunk.type === "text"}
- <p>{rendered.chunk.text}</p>
- {:else if rendered.chunk.type === "thinking"}
- <details>
- <summary>Thinking</summary>
- <p>{rendered.chunk.text}</p>
- </details>
- {:else if rendered.chunk.type === "tool-call"}
- <div class="text-sm">
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{JSON.stringify(rendered.chunk.input, null, 2)}</pre>
- </div>
- {:else if rendered.chunk.type === "tool-result"}
- <div class="text-sm" class:text-error={rendered.chunk.isError}>
- <strong>{rendered.chunk.toolName}</strong>
- <pre class="text-xs mt-1">{rendered.chunk.content}</pre>
- </div>
- {: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>
- {/each}
+{#snippet chunkRow(rendered: RenderedChunk)}
+ {#if rendered.role === "user"}
+ <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk
+ ([text, image, image, …]); each chunk renders in its own bubble. A
+ persisted image chunk's url is a compact relative path (`/images/…`)
+ served by the backend — resolve it against the API base. The
+ optimistic echo's data URL (and any absolute URL) passes through. -->
+ <div class="chat chat-start">
+ <div class="chat-bubble chat-bubble-primary">
+ {#if rendered.chunk.type === "text"}
+ <p>{rendered.chunk.text}</p>
+ {:else if rendered.chunk.type === "image"}
+ <img
+ src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)}
+ alt={rendered.chunk.mimeType ?? "pasted image"}
+ loading="lazy"
+ decoding="async"
+ class="max-h-80 max-w-full rounded"
+ />
+ {/if}
+ </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 pt-4 pb-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}
</div>
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte
new file mode 100644
index 0000000..5014e5c
--- /dev/null
+++ b/src/features/chat/ui/CompactionView.svelte
@@ -0,0 +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 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();
+
+ const DEFAULT_PERCENT = 85;
+
+ 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);
+
+ // 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}%`,
+ );
+
+ 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;
+ }
+ }
+</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>
+
+ <!-- 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 3762340..04c28cd 100644
--- a/src/features/chat/ui/Composer.svelte
+++ b/src/features/chat/ui/Composer.svelte
@@ -1,33 +1,413 @@
<script lang="ts">
- let { onSend }: { onSend: (text: string) => void } = $props();
-
- let text = $state("");
-
- function handleSubmit(): void {
- const trimmed = text.trim();
- if (trimmed.length === 0) return;
- onSend(trimmed);
- text = "";
- }
-
- function handleKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
+ import type { ImageInput } from "@dispatch/wire";
+ import { computeContextUsage, formatCompactTokens } from "../../../core/metrics";
+
+ const FALLBACK_CONTEXT_WINDOW = 1_000_000;
+ const MAX_LINES = 7;
+ /** Accept only raster images (the provider image-content formats). */
+ const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp";
+ /** Reject images larger than this before base64-encoding (keeps payloads sane). */
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
+
+ /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */
+ interface StagedImage {
+ readonly id: string;
+ readonly input: ImageInput;
+ }
+
+ let {
+ onSend,
+ onQueue,
+ onStop,
+ contextSize = undefined,
+ contextWindow = undefined,
+ status = "idle",
+ }: {
+ /**
+ * Send a message (start a turn via `chat.send`). Carries any staged images
+ * as `ImageInput[]` (base64 data URLs or https URLs); the store forwards
+ * them on the WS `chat.send` op / `POST /chat` body. `images` is omitted
+ * (not an empty array) when none are staged, so the wire stays text-only.
+ */
+ onSend: (text: string, images?: ImageInput[]) => 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. Steering is text-only —
+ * it never carries images (a mid-turn injection has no image surface).
+ */
+ onQueue?: (text: string) => void;
+ /** Stop the in-flight generation (`POST /conversations/:id/stop`). */
+ onStop?: () => void;
+ // Current context occupancy — updated progressively during a turn (the
+ // latest step's input+output) and finalized to the turn's `contextSize` on
+ // seal, or `undefined` when unknown — the status bar then shows
+ // "— 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. `queued` = the turn is in
+ * flight but waiting for a concurrency slot (CR-13) — shown as a loading
+ * RING (vs the loading DOTS of `running`/actively generating). Behaves like
+ * `running` for the send button (steer/stop).
+ */
+ status?: ComposerStatus;
+ } = $props();
+
+ export type ComposerStatus = "idle" | "running" | "queued" | "error";
+
+ let text = $state("");
+ let images = $state<StagedImage[]>([]);
+ let inputEl: HTMLTextAreaElement | undefined;
+ let fileInputEl: HTMLInputElement | undefined;
+ let dragOver = $state(false);
+
+ const hasText = $derived(text.trim().length > 0);
+ const hasImages = $derived(images.length > 0);
+ const canSend = $derived(hasText || hasImages);
+ const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW);
+ const usage = $derived(computeContextUsage(contextSize, effectiveMax));
+
+ // One button, three modes:
+ // - idle → "Send" (starts a turn via chat.send)
+ // - running/queued + text → "Queue" (steers via chat.queue — text only)
+ // - running/queued + empty → "Stop" (aborts via POST /stop)
+ // (`queued` behaves like `running` — the turn is in flight, just waiting for a
+ // concurrency slot; the user can still steer or stop it.)
+ // Steering never carries images: when running with images staged but no text,
+ // the images stay staged (queue is text-only). Images-without-text while running
+ // is an unusual case that still sends (the server auto-starts/resolves).
+ const inFlight = $derived(status === "running" || status === "queued");
+ const buttonMode = $derived.by<"send" | "queue" | "stop">(() => {
+ if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop";
+ if (inFlight && hasText && onQueue !== undefined) return "queue";
+ return "send";
+ });
+ const placeholder = $derived(
+ status === "queued"
+ ? "Queued for a slot…"
+ : status === "running"
+ ? "Steer the conversation..."
+ : "Type a message, paste or drop an image…",
+ );
+
+ // 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";
+ }
+
+ // Re-run resize whenever the value changes (covers programmatic clears too).
+ $effect(() => {
+ void text;
+ resize();
+ });
+
+ /** Read a File into a base64 data URL (`data:image/…;base64,…`). */
+ function fileToDataUrl(file: File): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ if (typeof reader.result === "string") resolve(reader.result);
+ else reject(new Error("unreadable image"));
+ };
+ reader.onerror = () => reject(reader.error ?? new Error("read failed"));
+ reader.readAsDataURL(file);
+ });
+ }
+
+ let imgSeq = 0;
+ /** Stage a File as an image (skip non-images / oversized). Returns whether staged. */
+ async function stageFile(file: File): Promise<boolean> {
+ if (!file.type.startsWith("image/")) return false;
+ if (file.size > MAX_IMAGE_BYTES) return false;
+ const url = await fileToDataUrl(file);
+ // Prefer the file's declared MIME; fall back to the data URL's prefix.
+ const mimeType = file.type || undefined;
+ const id = `img-${Date.now()}-${imgSeq++}`;
+ images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }];
+ return true;
+ }
+
+ function removeImage(id: string): void {
+ images = images.filter((img) => img.id !== id);
+ }
+
+ /** Handle a paste anywhere in the form: extract image items from the clipboard. */
+ async function handlePaste(e: ClipboardEvent): Promise<void> {
+ const items = e.clipboardData?.items;
+ if (items === undefined) return;
+ let hadImage = false;
+ const staged: File[] = [];
+ for (const item of items) {
+ if (item.kind === "file" && item.type.startsWith("image/")) {
+ const file = item.getAsFile();
+ if (file !== null) {
+ staged.push(file);
+ hadImage = true;
+ }
+ }
+ }
+ if (!hadImage) return; // let the default text paste proceed
+ e.preventDefault(); // suppress pasting the image as a filename string
+ for (const file of staged) {
+ await stageFile(file);
+ }
+ }
+
+ /** File-picker <input type="file"> change. */
+ async function handleFilePick(e: Event): Promise<void> {
+ const target = e.currentTarget as HTMLInputElement;
+ const files = target.files;
+ if (files === null) return;
+ for (const file of files) {
+ await stageFile(file);
+ }
+ target.value = ""; // reset so picking the same file again re-fires change
+ }
+
+ /** Drop images onto the composer. */
+ async function handleDrop(e: DragEvent): Promise<void> {
+ dragOver = false;
+ const files = e.dataTransfer?.files;
+ if (files === undefined || files.length === 0) return;
+ const hadImage = Array.from(files).some((f) => f.type.startsWith("image/"));
+ if (!hadImage) return;
+ e.preventDefault();
+ for (const file of files) {
+ await stageFile(file);
+ }
+ }
+
+ function handleSubmit(): void {
+ const trimmed = text.trim();
+ // Allow a send with images even when text is empty (an image-only turn).
+ if (trimmed.length === 0 && !hasImages) return;
+ if (buttonMode === "queue") {
+ // Steering is text-only — never forward images.
+ onQueue?.(trimmed);
+ } else {
+ const toSend: ImageInput[] | undefined = hasImages
+ ? images.map((img) => img.input)
+ : undefined;
+ onSend(trimmed, toSend);
+ }
+ text = "";
+ images = [];
+ }
+
+ function handleKeydown(e: KeyboardEvent): void {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
</script>
-<form class="flex gap-2 p-4" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
- <textarea
- class="textarea textarea-bordered flex-1"
- bind:value={text}
- onkeydown={handleKeydown}
- placeholder="Type a message..."
- rows="3"
- aria-label="Message input"
- ></textarea>
- <button class="btn btn-primary" type="submit" disabled={text.trim().length === 0}>
- Send
- </button>
+<form
+ class="flex flex-col"
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
+ ondrop={handleDrop}
+ ondragover={(e) => {
+ if (e.dataTransfer?.types?.includes("Files")) {
+ e.preventDefault();
+ dragOver = true;
+ }
+ }}
+ ondragleave={() => (dragOver = false)}
+>
+ <!-- Top bar: expanding textarea + image-attach button + single context-aware button -->
+ <div class="flex items-end gap-2 px-4 pt-3 pb-2">
+ <div
+ class="flex-1"
+ onpaste={handlePaste}
+ class:border-2={dragOver}
+ class:border-primary={dragOver}
+ class:border-dashed={dragOver}
+ class:rounded={dragOver}
+ >
+ <textarea
+ bind:this={inputEl}
+ class="textarea textarea-bordered w-full resize-none leading-normal !min-h-0 h-auto"
+ bind:value={text}
+ onkeydown={handleKeydown}
+ {placeholder}
+ rows="1"
+ aria-label="Message input"></textarea>
+ </div>
+
+ <!-- Hidden file picker (images only; multiple). -->
+ <input
+ bind:this={fileInputEl}
+ type="file"
+ accept={IMAGE_ACCEPT}
+ multiple
+ class="hidden"
+ onchange={handleFilePick}
+ />
+ <!-- Attach image button (opens the file picker). -->
+ <button
+ class="btn btn-ghost btn-square shrink-0"
+ type="button"
+ aria-label="Attach image"
+ title="Attach image"
+ onclick={() => fileInputEl?.click()}
+ >
+ <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-5 w-5"
+ >
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
+ <circle cx="8.5" cy="8.5" r="1.5"></circle>
+ <polyline points="21 15 16 10 5 21"></polyline>
+ </svg>
+ </button>
+
+ {#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={!canSend}>
+ {buttonMode === "queue" ? "Queue" : "Send"}
+ </button>
+ {/if}
+ </div>
+
+ <!-- Staged image thumbnails (previews) with remove buttons. -->
+ {#if hasImages}
+ <div class="flex flex-wrap gap-2 px-4 pb-1">
+ {#each images as img (img.id)}
+ <div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300">
+ <img
+ src={img.input.url}
+ alt={img.input.mimeType ?? "staged image"}
+ class="h-full w-full object-cover"
+ />
+ <button
+ class="btn btn-circle btn-xs absolute right-0 top-0 bg-base-100/80 hover:bg-error hover:text-error-content"
+ type="button"
+ aria-label="Remove image"
+ onclick={() => removeImage(img.id)}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-3 w-3"
+ >
+ <line x1="18" y1="6" x2="6" y2="18"></line>
+ <line x1="6" y1="6" x2="18" y2="18"></line>
+ </svg>
+ </button>
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- 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 === "queued"}
+ <!-- Waiting for a concurrency slot — a ring (vs the dots of `running`). -->
+ <span
+ class="loading loading-ring loading-xs text-primary"
+ aria-label="Queued"
+ title="Waiting for a concurrency slot"
+ ></span>
+ {:else if status === "running"}
+ <span class="loading loading-dots 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}
+
+ <span class="shrink-0 whitespace-nowrap font-mono">
+ {#if usage.current !== null}
+ {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 3e25ec3..11b9feb 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,22 +1,92 @@
<script lang="ts">
- let {
- models,
- selected,
- onSelect,
- }: {
- models: readonly string[];
- selected: string;
- onSelect: (model: string) => void;
- } = $props();
+ import type { ModelMetadata } from "@dispatch/transport-contract";
+ import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+
+ let {
+ models,
+ selected,
+ onSelect,
+ modelInfo = {},
+ }: {
+ models: readonly string[];
+ selected: string;
+ onSelect: (model: string) => void;
+ /**
+ * Per-model metadata from `GET /models` (`{ [name]: ModelMetadata }`).
+ * Used to show a "vision" badge next to models with `vision: true` (they
+ * natively accept images; others rely on the server's vision handoff).
+ * Optional — absent metadata → no badge (treated as non-vision).
+ */
+ modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ } = $props();
+
+ const keys = $derived(modelKeys(models));
+ const current = $derived(splitModelName(selected));
+ const keyModels = $derived(modelsForKey(models, current.key));
+
+ // Whether the currently-selected full model name is vision-capable.
+ const selectedVision = $derived(isVisionModel(modelInfo, selected));
+
+ // Switching key jumps to the first model available under it.
+ function selectKey(key: string): void {
+ const first = modelsForKey(models, key)[0] ?? "";
+ onSelect(joinModelName(key, first));
+ }
+
+ function selectModel(model: string): void {
+ onSelect(joinModelName(current.key, model));
+ }
+
+ // The full `<key>/<model>` name for a model suffix under the current key.
+ function fullNameFor(modelSuffix: string): string {
+ return joinModelName(current.key, modelSuffix);
+ }
</script>
-<select
- class="select"
- value={selected}
- onchange={(e) => onSelect(e.currentTarget.value)}
- aria-label="Model selector"
->
- {#each models as model (model)}
- <option value={model}>{model}</option>
- {/each}
-</select>
+<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}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+ </option>
+ {/each}
+ </select>
+ {#if selectedVision}
+ <div class="flex items-center gap-1 text-xs text-base-content/60">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-3.5 w-3.5"
+ aria-hidden="true"
+ >
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+ <circle cx="12" cy="12" r="3"></circle>
+ </svg>
+ <span>Vision — this model sees images natively</span>
+ </div>
+ {:else}
+ <div class="text-xs text-base-content/40">
+ Pasted images are auto-described (vision handoff)
+ </div>
+ {/if}
+</div>
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
new file mode 100644
index 0000000..d982905
--- /dev/null
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ 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();
+
+ 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);
+
+ 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
+ }
+ }
+</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}
+</div>